← Back to Blog

Flutter Buttons, Chips, Inputs and Text Widgets: Localization Done Right

flutterbuttonstextfieldwidgetslocalization

Flutter Buttons, Chips, Inputs and Text Widgets: Localization Done Right

Buttons and inputs hold most of an app's translated strings: labels, hints, validation errors, tooltips. This guide covers every Material button, chip, selection control, text field and text widget in one place, with the localization rule for each. It replaces the per-widget posts we used to publish, and it is the one to bookmark.

Every snippet assumes gen-l10n with context.l10n as a short accessor:

extension L10nX on BuildContext {
  AppLocalizations get l10n => AppLocalizations.of(this);
}

Buttons: ElevatedButton, FilledButton, OutlinedButton, TextButton, IconButton, FloatingActionButton

A button label is one key. The three mistakes are concatenation, fixed widths and icon direction.

FilledButton.icon(
  onPressed: submit,
  icon: const Icon(Icons.arrow_forward),      // mirrors in RTL on its own
  label: Text(context.l10n.continueToPayment), // one key, never "Continue to " + step
)

Do not give a button a fixed width. Give the row a Wrap or a LayoutBuilder so two long German labels stack instead of overflowing. If a label really must fit one line (a toolbar), wrap the Text in FittedBox(fit: BoxFit.scaleDown).

IconButton and FloatingActionButton have no visible text, so they need a tooltip for hover and screen readers: IconButton(tooltip: context.l10n.addItem, ...). The tooltip is a normal key. For the FAB, FloatingActionButton.extended takes a label like any button.

Icons that mean direction (arrow_forward, arrow_back, chevron_right, send, reply) are mirrored by Flutter automatically because they are listed with matchTextDirection. A custom Icon from your own font is not; wrap it in Transform.flip(flipX: Directionality.of(context) == TextDirection.rtl).

Chips: Chip, ActionChip, ChoiceChip, FilterChip, InputChip

Chips are short, so they tempt people into fixed sizes. Resist. Put chips in a Wrap and let Spanish push one to the next line.

Wrap(
  spacing: 8,
  children: [
    for (final filter in filters)
      FilterChip(
        label: Text(context.l10n.filterLabel(filter.key)),
        selected: selected.contains(filter),
        onSelected: (_) => toggle(filter),
      ),
  ],
)

The deleteButtonTooltipMessage on InputChip and Chip defaults to the Material localization ("Delete") and follows the locale through flutter_localizations. Override it only if you need a product-specific word. ChoiceChip and ActionChip have no extra strings. Chip avatars sit on the leading side and move in RTL by themselves.

Selection: Checkbox, CheckboxListTile, Radio, RadioListTile, Switch, SwitchListTile, Slider, RangeSlider, ToggleButtons, SegmentedButton

CheckboxListTile, RadioListTile and SwitchListTile put the control on the trailing side by default and flip it in RTL. The title is a key; the subtitle, when you add one, is a second key, not the title with a suffix.

SwitchListTile(
  title: Text(context.l10n.notificationsTitle),
  subtitle: Text(context.l10n.notificationsSubtitle),
  value: enabled,
  onChanged: setEnabled,
)

Slider and RangeSlider show a label while dragging. Format it with the locale, never with toString():

Slider(
  value: price,
  max: 500,
  divisions: 50,
  label: NumberFormat.currency(locale: Localizations.localeOf(context).toString(), symbol: '€').format(price),
  onChanged: setPrice,
)

Arabic locales format digits as Eastern Arabic numerals in NumberFormat, which is what your users expect. ToggleButtons and SegmentedButton lay their children out in text direction, so the first option sits on the right in Hebrew; do not hard-code "the left one is default".

Text input: TextField, TextFormField, InputDecoration, Autocomplete, SearchBar, SearchAnchor

InputDecoration has four translatable strings: labelText, hintText, helperText, errorText, plus prefixText and suffixText. The validator message is the one teams forget because it lives in a function:

TextFormField(
  decoration: InputDecoration(
    labelText: context.l10n.emailLabel,
    hintText: context.l10n.emailHint,
  ),
  validator: (value) =>
      value != null && value.contains('@') ? null : context.l10n.emailInvalid,
)

A validator runs on submit, after the build that captured context, which is fine as long as context came from the form's widget. For a validator in a separate class, pass the AppLocalizations instance in, or look it up without context as in AppLocalizations without BuildContext.

Text direction inside the field follows the first strong character of the typed text, not the app locale. For an email or URL field in an Arabic app, set textDirection: TextDirection.ltr explicitly so the cursor does not jump. keyboardType is not localized and should not be; the OS picks the keyboard layout.

Autocomplete, SearchBar and SearchAnchor show hintText and the option labels; both are keys. Match user input against the translated label with a locale-aware comparison (toLowerCase() is wrong for Turkish "I"), see locale-aware sorting and comparison in Dart.

Text widgets: Text, RichText, SelectableText, DefaultTextStyle, Tooltip

Text is where plurals and placeholders happen. Use ICU plurals in the ARB and call the generated method; never pick the form in Dart.

"itemCount": "{count, plural, =0{No items} one{1 item} other{{count} items}}"
Text(context.l10n.itemCount(cart.length))

RichText and Text.rich are the hard case: a sentence with a bold word or a link in the middle. The order of the parts changes per language, so the whole sentence must be one key with a placeholder, split at render time:

"termsNotice": "By continuing you accept the {terms}.",
"termsLink": "Terms of Service"
final text = context.l10n.termsNotice('\u0000');
final parts = text.split('\u0000');
Text.rich(TextSpan(children: [
  TextSpan(text: parts[0]),
  TextSpan(text: context.l10n.termsLink, style: linkStyle, recognizer: tapTerms),
  TextSpan(text: parts[1]),
]));

SelectableText behaves like Text and additionally shows the "Copy" and "Select all" context menu, which flutter_localizations translates for you. DefaultTextStyle is how you raise the line height for Thai or Devanagari once, at the top of a screen, instead of per widget:

final tall = {'th', 'hi', 'mr', 'ne'}.contains(Localizations.localeOf(context).languageCode);
DefaultTextStyle.merge(style: TextStyle(height: tall ? 1.6 : 1.3), child: body);

Tooltip(message:) is a key. Long tooltips in German wrap automatically, nothing to do.

Progress and feedback: ProgressIndicator, Badge, Tooltip

CircularProgressIndicator has a semanticsLabel that screen readers announce; set it to a key such as context.l10n.loading. Badge(label:) shows a count. Format it with NumberFormat.compact(locale:) so 12,000 becomes "12K" in English and "12 тыс." in Russian.

The strings you do not have to translate

flutter_localizations already covers Material and Cupertino built-in text: the "OK" and "Cancel" of a date picker, "Copy", "Paste", "Select all", the back button tooltip, "Show menu", the delete tooltip on chips, the drawer open/close labels. You only need the delegates in MaterialApp:

localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,

If one of those built-in strings reads wrong for your product, override it with a custom MaterialLocalizations delegate rather than rebuilding the widget.

Checklist

  1. Every Text('...') literal in lib/ is a bug. Grep for Text(' and Text(".
  2. Every validator returns a key, not a literal.
  3. Every icon-only button has a tooltip.
  4. No fixed-width buttons; chips and button rows sit in a Wrap.
  5. Numbers in labels go through NumberFormat.
  6. Run the app in Locale('de') for length and Locale('ar') for direction.

Related

Every key above still has to exist in every language. FlutterLocalisation adds the key once, translates it with AI into all your locales, and commits the ARB files to Git.