← Back to Blog

Flutter InputChip Localization: User-Generated Tags for Multilingual Apps

flutterinputchipchiptagslocalizationrtl

Flutter InputChip Localization: User-Generated Tags for Multilingual Apps

InputChip is a Flutter Material widget that represents a complex piece of information in a compact form, such as a contact, tag, or attribute. In multilingual applications, InputChip is essential for displaying user-generated translated tags that can be removed, handling variable chip widths for translated labels and user input, supporting RTL chip flow with delete button positioning, and providing accessible chip descriptions and removal actions in the active language.

Understanding InputChip in Localization Context

InputChip renders a Material Design chip that can be selected, deleted, or pressed, often used for tags, contacts, and user-generated entries. For multilingual apps, this enables:

  • Translated tag labels with delete functionality
  • User-generated chips from localized text input
  • RTL-aware chip layout with correctly positioned delete icons
  • Accessible removal announcements in the active language

Why InputChip Matters for Multilingual Apps

InputChip provides:

  • Deletable tags: Users can remove translated tags with the delete icon
  • Pressable action: Chips can trigger actions with localized feedback
  • Avatar support: Leading images or icons alongside translated labels
  • Compact display: Multiple user-generated entries in a wrappable layout

Basic InputChip Implementation

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizedInputChipExample extends StatefulWidget {
  const LocalizedInputChipExample({super.key});

  @override
  State<LocalizedInputChipExample> createState() =>
      _LocalizedInputChipExampleState();
}

class _LocalizedInputChipExampleState
    extends State<LocalizedInputChipExample> {
  late List<String> _tags;
  final TextEditingController _controller = TextEditingController();

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final l10n = AppLocalizations.of(context)!;
    _tags = [
      l10n.flutterTag,
      l10n.dartTag,
      l10n.localizationTag,
    ];
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.tagsTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.addTagsLabel,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: _tags.map((tag) {
                return InputChip(
                  label: Text(tag),
                  onDeleted: () {
                    setState(() => _tags.remove(tag));
                  },
                  deleteButtonTooltipMessage: l10n.removeTagTooltip,
                );
              }).toList(),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _controller,
              decoration: InputDecoration(
                hintText: l10n.newTagHint,
                border: const OutlineInputBorder(),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.add),
                  tooltip: l10n.addTagTooltip,
                  onPressed: () {
                    final text = _controller.text.trim();
                    if (text.isNotEmpty) {
                      setState(() => _tags.add(text));
                      _controller.clear();
                    }
                  },
                ),
              ),
              onSubmitted: (text) {
                if (text.trim().isNotEmpty) {
                  setState(() => _tags.add(text.trim()));
                  _controller.clear();
                }
              },
            ),
          ],
        ),
      ),
    );
  }
}

Advanced InputChip Patterns for Localization

Contact Chips with Avatars

InputChips that display contact names with avatar initials and translated removal tooltips.

class ContactInputChips extends StatefulWidget {
  const ContactInputChips({super.key});

  @override
  State<ContactInputChips> createState() => _ContactInputChipsState();
}

class _ContactInputChipsState extends State<ContactInputChips> {
  final List<_Contact> _recipients = [
    _Contact('Alice', 'alice@example.com'),
    _Contact('Bob', 'bob@example.com'),
  ];

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.recipientsLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
          const SizedBox(height: 8),
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              border: Border.all(
                color: Theme.of(context).colorScheme.outline,
              ),
              borderRadius: BorderRadius.circular(8),
            ),
            child: Wrap(
              spacing: 8,
              runSpacing: 4,
              children: [
                ..._recipients.map((contact) {
                  return InputChip(
                    avatar: CircleAvatar(
                      backgroundColor:
                          Theme.of(context).colorScheme.primaryContainer,
                      child: Text(
                        contact.name[0],
                        style: TextStyle(
                          color: Theme.of(context)
                              .colorScheme
                              .onPrimaryContainer,
                          fontSize: 12,
                        ),
                      ),
                    ),
                    label: Text(contact.name),
                    deleteButtonTooltipMessage:
                        l10n.removeRecipientTooltip(contact.name),
                    onDeleted: () {
                      setState(() => _recipients.remove(contact));
                    },
                    onPressed: () {
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(
                          content:
                              Text(l10n.contactDetailsMessage(contact.email)),
                        ),
                      );
                    },
                  );
                }),
                SizedBox(
                  width: 150,
                  child: TextField(
                    decoration: InputDecoration(
                      hintText: l10n.addRecipientHint,
                      border: InputBorder.none,
                      isDense: true,
                    ),
                    onSubmitted: (value) {
                      if (value.trim().isNotEmpty) {
                        setState(() {
                          _recipients.add(
                            _Contact(value.trim(), '${value.trim()}@example.com'),
                          );
                        });
                      }
                    },
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 8),
          Text(
            l10n.recipientCountLabel(_recipients.length),
            style: Theme.of(context).textTheme.bodySmall?.copyWith(
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
                ),
          ),
        ],
      ),
    );
  }
}

class _Contact {
  final String name;
  final String email;

  _Contact(this.name, this.email);
}

Skill Tags with Suggestions

InputChips for selected skills with a translated suggestion list.

class SkillInputChips extends StatefulWidget {
  const SkillInputChips({super.key});

  @override
  State<SkillInputChips> createState() => _SkillInputChipsState();
}

class _SkillInputChipsState extends State<SkillInputChips> {
  final Set<String> _selectedSkills = {};

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    final availableSkills = {
      'flutter': l10n.flutterSkill,
      'dart': l10n.dartSkill,
      'ios': l10n.iosSkill,
      'android': l10n.androidSkill,
      'react': l10n.reactSkill,
      'python': l10n.pythonSkill,
      'design': l10n.designSkill,
      'testing': l10n.testingSkill,
    };

    final unselectedSkills = availableSkills.entries
        .where((e) => !_selectedSkills.contains(e.key))
        .toList();

    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.yourSkillsLabel,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 8),
          if (_selectedSkills.isNotEmpty)
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: _selectedSkills.map((key) {
                return InputChip(
                  label: Text(availableSkills[key]!),
                  onDeleted: () {
                    setState(() => _selectedSkills.remove(key));
                  },
                  deleteButtonTooltipMessage: l10n.removeSkillTooltip,
                );
              }).toList(),
            )
          else
            Text(
              l10n.noSkillsSelectedLabel,
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: Theme.of(context).colorScheme.onSurfaceVariant,
                  ),
            ),
          const SizedBox(height: 16),
          Text(
            l10n.suggestedSkillsLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            children: unselectedSkills.map((entry) {
              return ActionChip(
                avatar: const Icon(Icons.add, size: 16),
                label: Text(entry.value),
                onPressed: () {
                  setState(() => _selectedSkills.add(entry.key));
                },
              );
            }).toList(),
          ),
        ],
      ),
    );
  }
}

Editable Tag List with Validation

InputChips with translated validation messages for tag input.

class ValidatedTagInput extends StatefulWidget {
  const ValidatedTagInput({super.key});

  @override
  State<ValidatedTagInput> createState() => _ValidatedTagInputState();
}

class _ValidatedTagInputState extends State<ValidatedTagInput> {
  final List<String> _tags = [];
  final TextEditingController _controller = TextEditingController();
  String? _errorMessage;
  static const int _maxTags = 5;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _addTag(String tag, AppLocalizations l10n) {
    final trimmed = tag.trim();
    if (trimmed.isEmpty) return;

    if (_tags.length >= _maxTags) {
      setState(() => _errorMessage = l10n.maxTagsError(_maxTags));
      return;
    }

    if (_tags.contains(trimmed)) {
      setState(() => _errorMessage = l10n.duplicateTagError);
      return;
    }

    if (trimmed.length < 2) {
      setState(() => _errorMessage = l10n.tagTooShortError);
      return;
    }

    setState(() {
      _tags.add(trimmed);
      _errorMessage = null;
    });
    _controller.clear();
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.articleTagsLabel,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 4),
          Text(
            l10n.maxTagsHint(_maxTags),
            style: Theme.of(context).textTheme.bodySmall?.copyWith(
                  color: Theme.of(context).colorScheme.onSurfaceVariant,
                ),
          ),
          const SizedBox(height: 12),
          if (_tags.isNotEmpty) ...[
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: _tags.map((tag) {
                return InputChip(
                  label: Text(tag),
                  onDeleted: () {
                    setState(() {
                      _tags.remove(tag);
                      _errorMessage = null;
                    });
                  },
                  deleteButtonTooltipMessage: l10n.removeTagTooltip,
                );
              }).toList(),
            ),
            const SizedBox(height: 12),
          ],
          TextField(
            controller: _controller,
            enabled: _tags.length < _maxTags,
            decoration: InputDecoration(
              hintText: _tags.length < _maxTags
                  ? l10n.enterTagHint
                  : l10n.maxTagsReachedHint,
              errorText: _errorMessage,
              border: const OutlineInputBorder(),
              suffixIcon: IconButton(
                icon: const Icon(Icons.add),
                tooltip: l10n.addTagTooltip,
                onPressed: () => _addTag(_controller.text, l10n),
              ),
            ),
            onSubmitted: (value) => _addTag(value, l10n),
          ),
          const SizedBox(height: 8),
          Text(
            l10n.tagCountLabel(_tags.length, _maxTags),
            style: Theme.of(context).textTheme.bodySmall,
          ),
        ],
      ),
    );
  }
}

RTL Support and Bidirectional Layouts

InputChip works correctly in RTL layouts. The delete icon positions on the correct side, and chips flow from right to left inside a Wrap.

class BidirectionalInputChips extends StatefulWidget {
  const BidirectionalInputChips({super.key});

  @override
  State<BidirectionalInputChips> createState() =>
      _BidirectionalInputChipsState();
}

class _BidirectionalInputChipsState extends State<BidirectionalInputChips> {
  final List<String> _interests = [];

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_interests.isEmpty) {
      final l10n = AppLocalizations.of(context)!;
      _interests.addAll([
        l10n.travelInterest,
        l10n.photographyInterest,
        l10n.cookingInterest,
      ]);
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            l10n.interestsLabel,
            style: Theme.of(context).textTheme.titleSmall,
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 4,
            children: _interests.map((interest) {
              return InputChip(
                label: Text(interest),
                onDeleted: () {
                  setState(() => _interests.remove(interest));
                },
                deleteButtonTooltipMessage: l10n.removeInterestTooltip,
              );
            }).toList(),
          ),
        ],
      ),
    );
  }
}

Testing InputChip Localization

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() {
  Widget buildTestWidget({Locale locale = const Locale('en')}) {
    return MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const LocalizedInputChipExample(),
    );
  }

  testWidgets('InputChip renders localized tags', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(InputChip), findsWidgets);
  });

  testWidgets('InputChip deletion works', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    final deleteIcons = find.byIcon(Icons.cancel);
    await tester.tap(deleteIcons.first);
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });

  testWidgets('InputChip works in RTL', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();
    expect(tester.takeException(), isNull);
  });
}

Best Practices

  1. Provide deleteButtonTooltipMessage with a translated tooltip so screen readers announce the removal action in the active language.

  2. Use Wrap to layout InputChips so user-generated tags flow to the next line as more are added.

  3. Validate tag input with translated error messages for duplicates, length limits, and maximum tag count.

  4. Show a count indicator with a parameterized label like tagCountLabel(current, max) to communicate how many tags remain.

  5. Combine InputChips with a text field for tag entry, placing the field inline with chips for a seamless input experience.

  6. Test with RTL languages to verify the delete icon appears on the correct side and chips flow from right to left.

Conclusion

InputChip provides a deletable tag widget for Flutter apps that handles user-generated content in a compact, wrappable layout. For multilingual apps, it handles translated tag labels with delete functionality, supports RTL-aware chip flow and icon positioning, and integrates naturally with text input for tag entry. By combining InputChip with contact lists, skill selectors, and validated tag inputs, you can build tagging interfaces that work seamlessly in every supported language.

Further Reading