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
Provide
deleteButtonTooltipMessagewith a translated tooltip so screen readers announce the removal action in the active language.Use
Wrapto layout InputChips so user-generated tags flow to the next line as more are added.Validate tag input with translated error messages for duplicates, length limits, and maximum tag count.
Show a count indicator with a parameterized label like
tagCountLabel(current, max)to communicate how many tags remain.Combine InputChips with a text field for tag entry, placing the field inline with chips for a seamless input experience.
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.