← Back to Blog

Flutter RichText Localization: Complex Styled Text for Multilingual Apps

flutterrichtexttextspantypographylocalizationrtl

Flutter RichText Localization: Complex Styled Text for Multilingual Apps

RichText is a Flutter widget that displays text using multiple styles within a single paragraph. In multilingual applications, RichText is critical when translated content requires inline bold, italic, colored, or linked segments -- scenarios where a single Text widget with one style is insufficient and where bidirectional text mixing demands careful span ordering.

Understanding RichText in Localization Context

RichText renders a TextSpan tree where each span can have its own style, gesture recognizer, and semantic label. For multilingual apps, this enables:

  • Mixed-style translated text with bold keywords, colored highlights, and inline links
  • Correct bidirectional rendering when RTL and LTR spans intermix
  • Span-level gesture detection for localized inline tappable text
  • Complex typography with different font sizes and weights within a single paragraph

Why RichText Matters for Multilingual Apps

RichText provides:

  • Inline styling: Apply different styles to portions of translated strings
  • BiDi span ordering: TextSpan children render correctly in both LTR and RTL contexts
  • Gesture spans: Add tap handlers to specific words within translated text
  • Semantic annotations: Each span can carry accessibility labels in the active language

Basic RichText Implementation

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

class LocalizedRichTextExample extends StatelessWidget {
  const LocalizedRichTextExample({super.key});

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

    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            RichText(
              text: TextSpan(
                style: Theme.of(context).textTheme.bodyLarge,
                children: [
                  TextSpan(text: l10n.welcomePrefix),
                  TextSpan(
                    text: l10n.appName,
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      color: Theme.of(context).colorScheme.primary,
                    ),
                  ),
                  TextSpan(text: l10n.welcomeSuffix),
                ],
              ),
            ),
            const SizedBox(height: 16),
            RichText(
              text: TextSpan(
                style: Theme.of(context).textTheme.bodyMedium,
                children: [
                  TextSpan(text: l10n.featureIntro),
                  TextSpan(
                    text: l10n.featureHighlight,
                    style: const TextStyle(
                      fontWeight: FontWeight.w600,
                      decoration: TextDecoration.underline,
                    ),
                  ),
                  TextSpan(text: l10n.featureOutro),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Advanced RichText Patterns for Localization

Tappable Inline Links in Translated Text

Many translated strings contain inline links like "Read our privacy policy" where "privacy policy" must be tappable. RichText with TapGestureRecognizer handles this cleanly.

class RichTextWithLinks extends StatelessWidget {
  const RichTextWithLinks({super.key});

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

    return Padding(
      padding: const EdgeInsets.all(16),
      child: RichText(
        text: TextSpan(
          style: Theme.of(context).textTheme.bodyMedium,
          children: [
            TextSpan(text: l10n.agreementPrefix),
            TextSpan(
              text: l10n.termsOfServiceLink,
              style: TextStyle(
                color: Theme.of(context).colorScheme.primary,
                decoration: TextDecoration.underline,
              ),
              recognizer: TapGestureRecognizer()
                ..onTap = () {
                  Navigator.pushNamed(context, '/terms');
                },
            ),
            TextSpan(text: l10n.agreementMiddle),
            TextSpan(
              text: l10n.privacyPolicyLink,
              style: TextStyle(
                color: Theme.of(context).colorScheme.primary,
                decoration: TextDecoration.underline,
              ),
              recognizer: TapGestureRecognizer()
                ..onTap = () {
                  Navigator.pushNamed(context, '/privacy');
                },
            ),
            TextSpan(text: l10n.agreementSuffix),
          ],
        ),
      ),
    );
  }
}

Highlighted Search Results

When displaying search results with highlighted matches, RichText renders the matched portion in a distinct style while keeping surrounding translated context intact.

class HighlightedSearchResult extends StatelessWidget {
  final String fullText;
  final String query;

  const HighlightedSearchResult({
    super.key,
    required this.fullText,
    required this.query,
  });

  @override
  Widget build(BuildContext context) {
    final spans = <TextSpan>[];
    final lowerText = fullText.toLowerCase();
    final lowerQuery = query.toLowerCase();

    int start = 0;
    int index = lowerText.indexOf(lowerQuery, start);

    while (index != -1) {
      if (index > start) {
        spans.add(TextSpan(text: fullText.substring(start, index)));
      }
      spans.add(TextSpan(
        text: fullText.substring(index, index + query.length),
        style: TextStyle(
          backgroundColor: Theme.of(context)
              .colorScheme
              .primaryContainer
              .withValues(alpha: 0.5),
          fontWeight: FontWeight.bold,
        ),
      ));
      start = index + query.length;
      index = lowerText.indexOf(lowerQuery, start);
    }

    if (start < fullText.length) {
      spans.add(TextSpan(text: fullText.substring(start)));
    }

    return RichText(
      text: TextSpan(
        style: Theme.of(context).textTheme.bodyMedium,
        children: spans,
      ),
      maxLines: 3,
      overflow: TextOverflow.ellipsis,
    );
  }
}

Pricing Display with Mixed Typography

Pricing content often mixes currency symbols, numbers, and descriptive text across different sizes and weights. Each locale may format these differently.

class LocalizedPricingText extends StatelessWidget {
  const LocalizedPricingText({super.key});

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

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            RichText(
              textAlign: TextAlign.center,
              text: TextSpan(
                children: [
                  TextSpan(
                    text: l10n.pricePrefix,
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                  TextSpan(
                    text: l10n.priceAmount,
                    style: Theme.of(context).textTheme.displaySmall?.copyWith(
                      fontWeight: FontWeight.bold,
                      color: Theme.of(context).colorScheme.primary,
                    ),
                  ),
                  TextSpan(
                    text: l10n.pricePeriod,
                    style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 8),
            RichText(
              text: TextSpan(
                style: Theme.of(context).textTheme.bodySmall,
                children: [
                  TextSpan(text: l10n.originalPricePrefix),
                  TextSpan(
                    text: l10n.originalPriceAmount,
                    style: const TextStyle(
                      decoration: TextDecoration.lineThrough,
                    ),
                  ),
                  TextSpan(text: ' '),
                  TextSpan(
                    text: l10n.discountBadge,
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.error,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Status Messages with Inline Icons

RichText supports WidgetSpan to embed widgets inline with text, useful for status indicators and inline icons within translated messages.

class StatusRichText extends StatelessWidget {
  const StatusRichText({super.key});

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

    return RichText(
      text: TextSpan(
        style: Theme.of(context).textTheme.bodyMedium,
        children: [
          TextSpan(text: l10n.orderStatusPrefix),
          WidgetSpan(
            alignment: PlaceholderAlignment.middle,
            child: Padding(
              padding: const EdgeInsetsDirectional.only(
                start: 4,
                end: 4,
              ),
              child: Icon(
                Icons.check_circle,
                size: 16,
                color: Theme.of(context).colorScheme.primary,
              ),
            ),
          ),
          TextSpan(
            text: l10n.orderStatusDelivered,
            style: TextStyle(
              fontWeight: FontWeight.bold,
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
        ],
      ),
    );
  }
}

RTL Support and Bidirectional Layouts

RichText respects the ambient Directionality and renders TextSpan children in the correct visual order. When mixing LTR content like numbers or brand names within RTL text, the Unicode BiDi algorithm handles reordering automatically.

class BidirectionalRichText extends StatelessWidget {
  const BidirectionalRichText({super.key});

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

    return Padding(
      padding: const EdgeInsetsDirectional.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          RichText(
            textAlign: TextAlign.start,
            text: TextSpan(
              style: Theme.of(context).textTheme.bodyLarge,
              children: [
                TextSpan(text: l10n.poweredByPrefix),
                TextSpan(
                  text: ' Flutter ',
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                    color: Theme.of(context).colorScheme.primary,
                  ),
                ),
                TextSpan(text: l10n.poweredBySuffix),
              ],
            ),
          ),
          const SizedBox(height: 16),
          RichText(
            textAlign: TextAlign.start,
            text: TextSpan(
              style: Theme.of(context).textTheme.bodyMedium,
              children: [
                TextSpan(text: l10n.lastLoginPrefix),
                TextSpan(
                  text: ' 2026-02-15 14:30 ',
                  style: const TextStyle(fontWeight: FontWeight.w600),
                ),
                TextSpan(text: l10n.lastLoginSuffix),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Testing RichText 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 Scaffold(body: LocalizedRichTextExample()),
    );
  }

  testWidgets('RichText renders multiple styled spans', (tester) async {
    await tester.pumpWidget(buildTestWidget());
    await tester.pumpAndSettle();
    expect(find.byType(RichText), findsWidgets);
  });

  testWidgets('RichText renders in RTL for Arabic', (tester) async {
    await tester.pumpWidget(buildTestWidget(locale: const Locale('ar')));
    await tester.pumpAndSettle();

    final directionality = tester.widget<Directionality>(
      find.byType(Directionality).first,
    );
    expect(directionality.textDirection, TextDirection.rtl);
  });
}

Best Practices

  1. Split translation strings at style boundaries -- use separate ARB keys for prefixes, highlighted portions, and suffixes rather than embedding HTML-like markup in translations.

  2. Use TextAlign.start instead of left/right to ensure RichText alignment respects the ambient text direction.

  3. Dispose TapGestureRecognizer instances in StatefulWidgets to prevent memory leaks when using tappable spans.

  4. Use WidgetSpan for inline icons rather than concatenating icon fonts, ensuring proper alignment and locale-aware spacing.

  5. Set maxLines and overflow on RichText in constrained layouts, as styled spans can expand significantly in verbose languages like German.

  6. Test with long translations to verify that styled spans wrap correctly and don't break mid-word at style boundaries.

Conclusion

RichText is the foundation for complex styled text in Flutter, enabling inline bold, colored, linked, and icon-embedded content within a single paragraph. For multilingual apps, the key challenges are splitting translations at style boundaries, handling bidirectional span ordering, and managing tappable inline links across all locales. By structuring translations around TextSpan trees and using TextAlign.start for directional alignment, you can build RichText interfaces that render beautifully in every supported language.

Further Reading