Flutter FloatingActionButton Localization: Labels, Extended FABs, and Speed Dials
FloatingActionButtons (FABs) are prominent UI elements in Flutter apps, providing quick access to primary actions. While they often rely on icons, proper localization of tooltips, extended labels, and speed dial options ensures accessibility and comprehension across all languages. This guide covers everything you need to know about localizing FABs effectively.
Understanding FAB Localization Needs
FABs require localization for:
- Tooltips: Screen reader and long-press descriptions
- Extended labels: Text shown on extended FABs
- Semantic labels: Accessibility announcements
- Speed dial labels: Multiple action descriptions
- Context-aware actions: Different labels per screen
Basic FAB with Localized Tooltip
Every FAB should have a localized tooltip for accessibility:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedFAB extends StatelessWidget {
final VoidCallback onPressed;
const LocalizedFAB({
super.key,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return FloatingActionButton(
onPressed: onPressed,
tooltip: l10n.createNewItem,
child: const Icon(Icons.add),
);
}
}
ARB file entries:
{
"createNewItem": "Create new item",
"@createNewItem": {
"description": "Tooltip for the main FAB to create new items"
}
}
Extended FAB with Localized Label
Extended FABs display text alongside the icon:
class LocalizedExtendedFAB extends StatelessWidget {
final VoidCallback onPressed;
final bool isExtended;
const LocalizedExtendedFAB({
super.key,
required this.onPressed,
this.isExtended = true,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return FloatingActionButton.extended(
onPressed: onPressed,
tooltip: l10n.composeMessageTooltip,
icon: const Icon(Icons.edit),
label: Text(l10n.composeMessage),
isExtended: isExtended,
);
}
}
ARB entries:
{
"composeMessage": "Compose",
"@composeMessage": {
"description": "Label on extended FAB to compose a new message"
},
"composeMessageTooltip": "Write a new message",
"@composeMessageTooltip": {
"description": "Full tooltip for compose FAB"
}
}
Context-Aware FAB Labels
FABs often need different labels based on the current screen:
enum FABContext {
inbox,
contacts,
calendar,
notes,
photos,
}
class ContextAwareFAB extends StatelessWidget {
final FABContext fabContext;
final VoidCallback onPressed;
const ContextAwareFAB({
super.key,
required this.fabContext,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return FloatingActionButton.extended(
onPressed: onPressed,
tooltip: _getTooltip(l10n),
icon: Icon(_getIcon()),
label: Text(_getLabel(l10n)),
);
}
String _getLabel(AppLocalizations l10n) {
switch (fabContext) {
case FABContext.inbox:
return l10n.fabComposeEmail;
case FABContext.contacts:
return l10n.fabAddContact;
case FABContext.calendar:
return l10n.fabCreateEvent;
case FABContext.notes:
return l10n.fabNewNote;
case FABContext.photos:
return l10n.fabUploadPhoto;
}
}
String _getTooltip(AppLocalizations l10n) {
switch (fabContext) {
case FABContext.inbox:
return l10n.fabComposeEmailTooltip;
case FABContext.contacts:
return l10n.fabAddContactTooltip;
case FABContext.calendar:
return l10n.fabCreateEventTooltip;
case FABContext.notes:
return l10n.fabNewNoteTooltip;
case FABContext.photos:
return l10n.fabUploadPhotoTooltip;
}
}
IconData _getIcon() {
switch (fabContext) {
case FABContext.inbox:
return Icons.edit;
case FABContext.contacts:
return Icons.person_add;
case FABContext.calendar:
return Icons.event_available;
case FABContext.notes:
return Icons.note_add;
case FABContext.photos:
return Icons.add_photo_alternate;
}
}
}
ARB entries:
{
"fabComposeEmail": "Compose",
"@fabComposeEmail": {
"description": "FAB label for composing email"
},
"fabComposeEmailTooltip": "Write a new email",
"fabAddContact": "Add",
"@fabAddContact": {
"description": "FAB label for adding contact"
},
"fabAddContactTooltip": "Add a new contact",
"fabCreateEvent": "Event",
"@fabCreateEvent": {
"description": "FAB label for creating calendar event"
},
"fabCreateEventTooltip": "Create a new event",
"fabNewNote": "Note",
"@fabNewNote": {
"description": "FAB label for creating note"
},
"fabNewNoteTooltip": "Create a new note",
"fabUploadPhoto": "Upload",
"@fabUploadPhoto": {
"description": "FAB label for uploading photo"
},
"fabUploadPhotoTooltip": "Upload a new photo"
}
Speed Dial FAB with Localized Actions
Speed dials present multiple actions when the FAB is tapped:
class LocalizedSpeedDial extends StatefulWidget {
const LocalizedSpeedDial({super.key});
@override
State<LocalizedSpeedDial> createState() => _LocalizedSpeedDialState();
}
class _LocalizedSpeedDialState extends State<LocalizedSpeedDial>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
bool _isOpen = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_isOpen = !_isOpen;
if (_isOpen) {
_animationController.forward();
} else {
_animationController.reverse();
}
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Speed dial options
if (_isOpen) ...[
_SpeedDialOption(
icon: Icons.image,
label: l10n.speedDialAddImage,
tooltip: l10n.speedDialAddImageTooltip,
onPressed: () => _handleAction('image'),
),
const SizedBox(height: 8),
_SpeedDialOption(
icon: Icons.videocam,
label: l10n.speedDialAddVideo,
tooltip: l10n.speedDialAddVideoTooltip,
onPressed: () => _handleAction('video'),
),
const SizedBox(height: 8),
_SpeedDialOption(
icon: Icons.attach_file,
label: l10n.speedDialAddFile,
tooltip: l10n.speedDialAddFileTooltip,
onPressed: () => _handleAction('file'),
),
const SizedBox(height: 16),
],
// Main FAB
FloatingActionButton(
onPressed: _toggle,
tooltip: _isOpen ? l10n.speedDialClose : l10n.speedDialOpen,
child: AnimatedIcon(
icon: AnimatedIcons.menu_close,
progress: _animationController,
),
),
],
);
}
void _handleAction(String type) {
_toggle();
// Handle the specific action
}
}
class _SpeedDialOption extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final VoidCallback onPressed;
const _SpeedDialOption({
required this.icon,
required this.label,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(width: 12),
FloatingActionButton.small(
heroTag: 'speed_dial_$label',
onPressed: onPressed,
tooltip: tooltip,
child: Icon(icon),
),
],
);
}
}
ARB entries:
{
"speedDialOpen": "Show options",
"@speedDialOpen": {
"description": "Tooltip when speed dial is closed"
},
"speedDialClose": "Close options",
"@speedDialClose": {
"description": "Tooltip when speed dial is open"
},
"speedDialAddImage": "Add image",
"speedDialAddImageTooltip": "Attach an image from your gallery",
"speedDialAddVideo": "Add video",
"speedDialAddVideoTooltip": "Attach a video from your gallery",
"speedDialAddFile": "Add file",
"speedDialAddFileTooltip": "Attach a document or file"
}
Animated FAB with State-Based Labels
FABs that change state need corresponding label updates:
class AnimatedStateFAB extends StatefulWidget {
const AnimatedStateFAB({super.key});
@override
State<AnimatedStateFAB> createState() => _AnimatedStateFABState();
}
class _AnimatedStateFABState extends State<AnimatedStateFAB> {
bool _isPlaying = false;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (_isLoading) {
return FloatingActionButton(
onPressed: null,
tooltip: l10n.fabLoading,
child: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
);
}
return FloatingActionButton.extended(
onPressed: _togglePlayback,
tooltip: _isPlaying ? l10n.fabPauseTooltip : l10n.fabPlayTooltip,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow,
key: ValueKey(_isPlaying),
),
),
label: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_isPlaying ? l10n.fabPause : l10n.fabPlay,
key: ValueKey(_isPlaying),
),
),
);
}
Future<void> _togglePlayback() async {
setState(() => _isLoading = true);
// Simulate async operation
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_isLoading = false;
_isPlaying = !_isPlaying;
});
}
}
ARB entries:
{
"fabPlay": "Play",
"@fabPlay": {
"description": "FAB label to start playback"
},
"fabPlayTooltip": "Start playing the track",
"fabPause": "Pause",
"@fabPause": {
"description": "FAB label to pause playback"
},
"fabPauseTooltip": "Pause the current track",
"fabLoading": "Loading...",
"@fabLoading": {
"description": "FAB tooltip while loading"
}
}
FAB with Counter Badge
FABs can display counts that need proper number formatting:
class FABWithBadge extends StatelessWidget {
final int unreadCount;
final VoidCallback onPressed;
const FABWithBadge({
super.key,
required this.unreadCount,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
final numberFormat = NumberFormat.compact(locale: locale.toString());
return FloatingActionButton.extended(
onPressed: onPressed,
tooltip: l10n.fabMessagesTooltip(unreadCount),
icon: Badge(
isLabelVisible: unreadCount > 0,
label: Text(
unreadCount > 99 ? '99+' : numberFormat.format(unreadCount),
),
child: const Icon(Icons.mail),
),
label: Text(l10n.fabMessages),
);
}
}
ARB entries:
{
"fabMessages": "Messages",
"@fabMessages": {
"description": "FAB label for messages"
},
"fabMessagesTooltip": "{count, plural, =0{No new messages} =1{1 unread message} other{{count} unread messages}}",
"@fabMessagesTooltip": {
"description": "Tooltip showing unread message count",
"placeholders": {
"count": {
"type": "int"
}
}
}
}
RTL Support for FAB Layouts
Handle right-to-left layouts for speed dials and extended FABs:
class RTLAwareFAB extends StatelessWidget {
final List<SpeedDialAction> actions;
final bool isOpen;
final VoidCallback onToggle;
const RTLAwareFAB({
super.key,
required this.actions,
required this.isOpen,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final isRTL = Directionality.of(context) == TextDirection.rtl;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
isRTL ? CrossAxisAlignment.start : CrossAxisAlignment.end,
children: [
if (isOpen)
...actions.map((action) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
children: [
// Label comes first (on the left for LTR, right for RTL)
_buildLabel(context, action.label),
const SizedBox(width: 12),
// Small FAB
FloatingActionButton.small(
heroTag: action.id,
onPressed: action.onPressed,
tooltip: action.tooltip,
child: Icon(action.icon),
),
],
),
);
}),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: onToggle,
tooltip: isOpen ? l10n.fabClose : l10n.fabOpen,
child: Icon(isOpen ? Icons.close : Icons.add),
),
],
);
}
Widget _buildLabel(BuildContext context, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
),
],
),
child: Text(label),
);
}
}
class SpeedDialAction {
final String id;
final IconData icon;
final String label;
final String tooltip;
final VoidCallback onPressed;
const SpeedDialAction({
required this.id,
required this.icon,
required this.label,
required this.tooltip,
required this.onPressed,
});
}
Accessibility-First FAB
Ensure full accessibility with proper semantic labels:
class AccessibleFAB extends StatelessWidget {
final VoidCallback onPressed;
final String semanticLabel;
final String tooltip;
final IconData icon;
final bool isEnabled;
const AccessibleFAB({
super.key,
required this.onPressed,
required this.semanticLabel,
required this.tooltip,
required this.icon,
this.isEnabled = true,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
button: true,
enabled: isEnabled,
label: semanticLabel,
hint: isEnabled ? null : l10n.fabDisabledHint,
child: FloatingActionButton(
onPressed: isEnabled ? onPressed : null,
tooltip: tooltip,
child: Icon(
icon,
semanticLabel: semanticLabel,
),
),
);
}
}
// Usage
class TaskListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: const TaskListView(),
floatingActionButton: AccessibleFAB(
onPressed: () => _showCreateTaskDialog(context),
semanticLabel: l10n.fabCreateTaskSemantic,
tooltip: l10n.fabCreateTaskTooltip,
icon: Icons.add_task,
isEnabled: true,
),
);
}
void _showCreateTaskDialog(BuildContext context) {
// Show dialog
}
}
ARB entries:
{
"fabCreateTaskSemantic": "Create a new task",
"@fabCreateTaskSemantic": {
"description": "Screen reader announcement for create task FAB"
},
"fabCreateTaskTooltip": "Add a new task to your list",
"@fabCreateTaskTooltip": {
"description": "Tooltip for create task FAB"
},
"fabDisabledHint": "This action is currently unavailable",
"@fabDisabledHint": {
"description": "Hint for disabled FAB state"
}
}
FAB Menu with Subgroups
Organize complex actions into localized subgroups:
class GroupedSpeedDial extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SpeedDialMenu(
mainTooltip: l10n.fabMainMenu,
groups: [
SpeedDialGroup(
title: l10n.fabGroupCreate,
actions: [
SpeedDialAction(
id: 'new_document',
icon: Icons.description,
label: l10n.fabNewDocument,
tooltip: l10n.fabNewDocumentTooltip,
onPressed: () {},
),
SpeedDialAction(
id: 'new_spreadsheet',
icon: Icons.table_chart,
label: l10n.fabNewSpreadsheet,
tooltip: l10n.fabNewSpreadsheetTooltip,
onPressed: () {},
),
],
),
SpeedDialGroup(
title: l10n.fabGroupImport,
actions: [
SpeedDialAction(
id: 'import_file',
icon: Icons.upload_file,
label: l10n.fabImportFile,
tooltip: l10n.fabImportFileTooltip,
onPressed: () {},
),
SpeedDialAction(
id: 'scan_document',
icon: Icons.document_scanner,
label: l10n.fabScanDocument,
tooltip: l10n.fabScanDocumentTooltip,
onPressed: () {},
),
],
),
],
);
}
}
Testing FAB Localization
Write comprehensive tests for FAB labels:
void main() {
group('LocalizedFAB Tests', () {
testWidgets('displays correct tooltip', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: Scaffold(
floatingActionButton: LocalizedFAB(
onPressed: () {},
),
),
),
);
// Long press to show tooltip
final fab = find.byType(FloatingActionButton);
await tester.longPress(fab);
await tester.pumpAndSettle();
expect(find.text('Create new item'), findsOneWidget);
});
testWidgets('extended FAB shows label in Spanish', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: Scaffold(
floatingActionButton: LocalizedExtendedFAB(
onPressed: () {},
),
),
),
);
expect(find.text('Redactar'), findsOneWidget);
});
testWidgets('speed dial options have correct labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(
floatingActionButton: LocalizedSpeedDial(),
),
),
);
// Open speed dial
await tester.tap(find.byType(FloatingActionButton).first);
await tester.pumpAndSettle();
expect(find.text('Add image'), findsOneWidget);
expect(find.text('Add video'), findsOneWidget);
expect(find.text('Add file'), findsOneWidget);
});
});
}
Best Practices Summary
- Always provide tooltips: Every FAB needs a descriptive tooltip for accessibility
- Keep labels concise: Extended FAB labels should be 1-2 words
- Use semantic labels: Add proper screen reader descriptions
- Handle RTL layouts: Mirror speed dial positions for RTL languages
- Context-aware labels: Change FAB text based on the current screen
- Test all states: Verify labels for loading, enabled, and disabled states
- Pluralize counts: Use ICU message format for badge counts
Conclusion
Localizing FloatingActionButtons in Flutter requires attention to tooltips, extended labels, speed dial options, and accessibility. By implementing context-aware labels, proper RTL support, and comprehensive testing, you ensure your FABs communicate clearly to users worldwide. Remember that even icon-only FABs need localized tooltips for accessibility compliance.