← Back to Blog

Flutter Tooltip Localization: Hints, Help Text, and Placeholder Messages

fluttertooltiphintslocalizationaccessibilityhelp-text

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

  1. Keep tooltips concise - Short, actionable text works best
  2. Use consistent terminology - Same action should have same tooltip everywhere
  3. Consider touch vs mouse - Tooltips work differently on mobile
  4. Test with screen readers - Ensure semantic labels are helpful
  5. Don't duplicate information - Tooltip should add value, not repeat visible text
  6. 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.