Flutter Tooltip Localization: Hints, Help Text, and Contextual Guidance
Create helpful tooltips that guide users in any language. This guide covers localizing tooltips, help text, placeholder hints, and contextual guidance in Flutter applications.
Tooltip Localization Challenges
Tooltip features require localization for:
- Action hints - "Tap to edit", "Long press for options"
- Field placeholders - "Enter your email", "Search..."
- Help text - Explanations and instructions
- Error hints - Validation guidance
- Accessibility labels - Screen reader descriptions
Setting Up Tooltip Localization
ARB File Structure
{
"@@locale": "en",
"tooltipEdit": "Tap to edit",
"@tooltipEdit": {
"description": "Tooltip for edit action"
},
"tooltipDelete": "Delete this item",
"@tooltipDelete": {
"description": "Tooltip for delete action"
},
"tooltipShare": "Share with others",
"@tooltipShare": {
"description": "Tooltip for share action"
},
"tooltipMore": "More options",
"@tooltipMore": {
"description": "Tooltip for overflow menu"
},
"tooltipClose": "Close",
"@tooltipClose": {
"description": "Tooltip for close button"
},
"tooltipBack": "Go back",
"@tooltipBack": {
"description": "Tooltip for back navigation"
},
"tooltipRefresh": "Refresh content",
"@tooltipRefresh": {
"description": "Tooltip for refresh action"
},
"tooltipSearch": "Search",
"@tooltipSearch": {
"description": "Tooltip for search button"
},
"tooltipFilter": "Filter results",
"@tooltipFilter": {
"description": "Tooltip for filter button"
},
"tooltipSort": "Sort by",
"@tooltipSort": {
"description": "Tooltip for sort button"
}
}
Placeholder and Hint Text
{
"hintEmail": "Enter your email address",
"@hintEmail": {
"description": "Placeholder for email input"
},
"hintPassword": "Enter your password",
"@hintPassword": {
"description": "Placeholder for password input"
},
"hintSearch": "Search...",
"@hintSearch": {
"description": "Placeholder for search field"
},
"hintMessage": "Type a message",
"@hintMessage": {
"description": "Placeholder for message input"
},
"hintName": "Enter your full name",
"@hintName": {
"description": "Placeholder for name input"
},
"hintPhone": "Phone number",
"@hintPhone": {
"description": "Placeholder for phone input"
},
"hintAmount": "Enter amount",
"@hintAmount": {
"description": "Placeholder for amount input"
},
"hintDescription": "Add a description (optional)",
"@hintDescription": {
"description": "Placeholder for description input"
}
}
Implementing Localized Tooltips
Custom Tooltip Widget
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedTooltip extends StatelessWidget {
final Widget child;
final String message;
final TooltipPosition position;
final Duration showDuration;
final Duration waitDuration;
const LocalizedTooltip({
Key? key,
required this.child,
required this.message,
this.position = TooltipPosition.below,
this.showDuration = const Duration(seconds: 2),
this.waitDuration = const Duration(milliseconds: 500),
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Tooltip(
message: message,
preferBelow: position == TooltipPosition.below,
showDuration: showDuration,
waitDuration: waitDuration,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(8),
),
textStyle: const TextStyle(
color: Colors.white,
fontSize: 14,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: child,
);
}
}
enum TooltipPosition { above, below }
Icon Button with Localized Tooltip
class LocalizedIconButton extends StatelessWidget {
final IconData icon;
final String tooltipKey;
final VoidCallback onPressed;
final Color? color;
final double? size;
const LocalizedIconButton({
Key? key,
required this.icon,
required this.tooltipKey,
required this.onPressed,
this.color,
this.size,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final tooltip = _getTooltip(l10n);
return IconButton(
icon: Icon(icon, color: color, size: size),
tooltip: tooltip,
onPressed: onPressed,
);
}
String _getTooltip(AppLocalizations l10n) {
switch (tooltipKey) {
case 'edit':
return l10n.tooltipEdit;
case 'delete':
return l10n.tooltipDelete;
case 'share':
return l10n.tooltipShare;
case 'more':
return l10n.tooltipMore;
case 'close':
return l10n.tooltipClose;
case 'back':
return l10n.tooltipBack;
case 'refresh':
return l10n.tooltipRefresh;
case 'search':
return l10n.tooltipSearch;
case 'filter':
return l10n.tooltipFilter;
case 'sort':
return l10n.tooltipSort;
default:
return tooltipKey;
}
}
}
Localized Text Fields
Input Field with Hints and Help
class LocalizedTextField extends StatelessWidget {
final TextEditingController? controller;
final String hintKey;
final String? labelKey;
final String? helperKey;
final String? errorKey;
final TextInputType keyboardType;
final bool obscureText;
final Widget? prefixIcon;
final Widget? suffixIcon;
final ValueChanged<String>? onChanged;
final FormFieldValidator<String>? validator;
const LocalizedTextField({
Key? key,
this.controller,
required this.hintKey,
this.labelKey,
this.helperKey,
this.errorKey,
this.keyboardType = TextInputType.text,
this.obscureText = false,
this.prefixIcon,
this.suffixIcon,
this.onChanged,
this.validator,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
onChanged: onChanged,
validator: validator,
decoration: InputDecoration(
hintText: _getHint(l10n),
labelText: labelKey != null ? _getLabel(l10n) : null,
helperText: helperKey != null ? _getHelper(l10n) : null,
errorText: errorKey != null ? _getError(l10n) : null,
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
),
);
}
String _getHint(AppLocalizations l10n) {
switch (hintKey) {
case 'email':
return l10n.hintEmail;
case 'password':
return l10n.hintPassword;
case 'search':
return l10n.hintSearch;
case 'message':
return l10n.hintMessage;
case 'name':
return l10n.hintName;
case 'phone':
return l10n.hintPhone;
case 'amount':
return l10n.hintAmount;
case 'description':
return l10n.hintDescription;
default:
return hintKey;
}
}
String? _getLabel(AppLocalizations l10n) {
if (labelKey == null) return null;
// Map label keys to localized strings
return labelKey;
}
String? _getHelper(AppLocalizations l10n) {
if (helperKey == null) return null;
// Map helper keys to localized strings
return helperKey;
}
String? _getError(AppLocalizations l10n) {
if (errorKey == null) return null;
// Map error keys to localized strings
return errorKey;
}
}
Search Field with Localized Placeholder
class LocalizedSearchField extends StatefulWidget {
final ValueChanged<String>? onChanged;
final VoidCallback? onClear;
final String? initialValue;
const LocalizedSearchField({
Key? key,
this.onChanged,
this.onClear,
this.initialValue,
}) : super(key: key);
@override
State<LocalizedSearchField> createState() => _LocalizedSearchFieldState();
}
class _LocalizedSearchFieldState extends State<LocalizedSearchField> {
late TextEditingController _controller;
bool _hasText = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
_hasText = _controller.text.isNotEmpty;
_controller.addListener(_onTextChanged);
}
void _onTextChanged() {
setState(() {
_hasText = _controller.text.isNotEmpty;
});
widget.onChanged?.call(_controller.text);
}
void _clearSearch() {
_controller.clear();
widget.onClear?.call();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: l10n.hintSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: _hasText
? IconButton(
icon: const Icon(Icons.clear),
tooltip: l10n.tooltipClear,
onPressed: _clearSearch,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
);
}
}
Help Text and Instructions
Localized Help Card
class LocalizedHelpCard extends StatelessWidget {
final String titleKey;
final String descriptionKey;
final IconData? icon;
final VoidCallback? onLearnMore;
const LocalizedHelpCard({
Key? key,
required this.titleKey,
required this.descriptionKey,
this.icon,
this.onLearnMore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (icon != null) ...[
Icon(icon, color: Theme.of(context).primaryColor),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_getTitle(l10n),
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 8),
Text(
_getDescription(l10n),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
if (onLearnMore != null) ...[
const SizedBox(height: 12),
TextButton(
onPressed: onLearnMore,
child: Text(l10n.learnMore),
),
],
],
),
),
);
}
String _getTitle(AppLocalizations l10n) {
// Map title keys to localized strings
return titleKey;
}
String _getDescription(AppLocalizations l10n) {
// Map description keys to localized strings
return descriptionKey;
}
}
Contextual Help Overlay
class LocalizedHelpOverlay extends StatelessWidget {
final String helpKey;
final Widget child;
final bool showOnFirstVisit;
const LocalizedHelpOverlay({
Key? key,
required this.helpKey,
required this.child,
this.showOnFirstVisit = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return GestureDetector(
onLongPress: () => _showHelpDialog(context, l10n),
child: Stack(
children: [
child,
Positioned(
right: 4,
top: 4,
child: GestureDetector(
onTap: () => _showHelpDialog(context, l10n),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.help_outline,
size: 16,
color: Colors.blue,
),
),
),
),
],
),
);
}
void _showHelpDialog(BuildContext context, AppLocalizations l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
const Icon(Icons.lightbulb, color: Colors.amber),
const SizedBox(width: 8),
Text(l10n.helpTitle),
],
),
content: Text(_getHelpText(l10n)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.gotIt),
),
],
),
);
}
String _getHelpText(AppLocalizations l10n) {
// Map help keys to localized help text
return helpKey;
}
}
Feature Discovery and Onboarding Tips
Localized Feature Tip
class LocalizedFeatureTip extends StatefulWidget {
final String featureKey;
final Widget child;
final GlobalKey targetKey;
const LocalizedFeatureTip({
Key? key,
required this.featureKey,
required this.child,
required this.targetKey,
}) : super(key: key);
@override
State<LocalizedFeatureTip> createState() => _LocalizedFeatureTipState();
}
class _LocalizedFeatureTipState extends State<LocalizedFeatureTip> {
OverlayEntry? _overlayEntry;
bool _hasShown = false;
@override
void initState() {
super.initState();
_checkIfShouldShow();
}
Future<void> _checkIfShouldShow() async {
final prefs = await SharedPreferences.getInstance();
final hasShown = prefs.getBool('tip_${widget.featureKey}') ?? false;
if (!hasShown && mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showTip();
});
}
}
void _showTip() {
final l10n = AppLocalizations.of(context)!;
final overlay = Overlay.of(context);
final renderBox = widget.targetKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final position = renderBox.localToGlobal(Offset.zero);
final size = renderBox.size;
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// Semi-transparent background
GestureDetector(
onTap: _dismissTip,
child: Container(
color: Colors.black54,
),
),
// Spotlight on target
Positioned(
left: position.dx - 8,
top: position.dy - 8,
child: Container(
width: size.width + 16,
height: size.height + 16,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(8),
),
),
),
// Tip content
Positioned(
left: position.dx,
top: position.dy + size.height + 16,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getTipTitle(l10n),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(_getTipDescription(l10n)),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _dismissTip,
child: Text(l10n.gotIt),
),
),
],
),
),
),
),
],
),
);
overlay.insert(_overlayEntry!);
}
Future<void> _dismissTip() async {
_overlayEntry?.remove();
_overlayEntry = null;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('tip_${widget.featureKey}', true);
}
String _getTipTitle(AppLocalizations l10n) {
// Map feature keys to tip titles
return widget.featureKey;
}
String _getTipDescription(AppLocalizations l10n) {
// Map feature keys to tip descriptions
return widget.featureKey;
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: widget.targetKey,
child: widget.child,
);
}
}
Accessibility Labels
Semantic Tooltips for Screen Readers
class AccessibleTooltipButton extends StatelessWidget {
final IconData icon;
final String tooltipKey;
final String semanticLabelKey;
final VoidCallback onPressed;
const AccessibleTooltipButton({
Key? key,
required this.icon,
required this.tooltipKey,
required this.semanticLabelKey,
required this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
label: _getSemanticLabel(l10n),
button: true,
child: IconButton(
icon: Icon(icon),
tooltip: _getTooltip(l10n),
onPressed: onPressed,
),
);
}
String _getTooltip(AppLocalizations l10n) {
// Return localized tooltip
return tooltipKey;
}
String _getSemanticLabel(AppLocalizations l10n) {
// Return localized semantic label for screen readers
return semanticLabelKey;
}
}
Additional ARB Entries
{
"tooltipClear": "Clear",
"@tooltipClear": {
"description": "Tooltip for clear button"
},
"helpTitle": "Tip",
"@helpTitle": {
"description": "Title for help dialogs"
},
"gotIt": "Got it",
"@gotIt": {
"description": "Dismiss button for tips"
},
"learnMore": "Learn more",
"@learnMore": {
"description": "Learn more link text"
},
"hintOptional": "(optional)",
"@hintOptional": {
"description": "Suffix for optional fields"
},
"hintRequired": "Required",
"@hintRequired": {
"description": "Indicator for required fields"
},
"tooltipInfo": "More information",
"@tooltipInfo": {
"description": "Tooltip for info buttons"
},
"tooltipHelp": "Get help",
"@tooltipHelp": {
"description": "Tooltip for help buttons"
}
}
Testing Tooltip Localization
Widget Tests
void main() {
group('LocalizedTooltip', () {
testWidgets('displays localized tooltip message', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
locale: const Locale('es'),
home: Scaffold(
body: LocalizedIconButton(
icon: Icons.edit,
tooltipKey: 'edit',
onPressed: () {},
),
),
),
);
// Long press to show tooltip
await tester.longPress(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
expect(find.text('Toca para editar'), findsOneWidget);
});
testWidgets('shows localized placeholder in text field', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
locale: const Locale('fr'),
home: const Scaffold(
body: LocalizedTextField(hintKey: 'email'),
),
),
);
expect(find.text('Entrez votre adresse e-mail'), findsOneWidget);
});
});
}
Best Practices
- Keep tooltips concise - Short, actionable text works best
- Use consistent terminology - Same action should have same tooltip everywhere
- Consider touch vs mouse - Tooltips work differently on mobile
- Test with screen readers - Ensure semantic labels are helpful
- Don't duplicate information - Tooltip should add value, not repeat visible text
- Support RTL layouts - Tooltip positioning must work in RTL languages
Conclusion
Localized tooltips and help text guide users through your app in their native language. By implementing proper localization for hints, placeholders, and contextual help, you create an intuitive experience that reduces confusion and support requests.
Remember to test tooltips across all supported locales, paying attention to text length differences that might affect layout and positioning.