← Articles

Building a custom theme system in Flutter

By Ann Tech · 16 July 2025

Flutter's built-in ThemeData covers most UI customization, but production apps often need things it doesn't support out of the box: per-brand color palettes in a white-label app, custom shadow tokens, or component-level styles that go beyond what Material exposes. Here is how to build a theme system that scales.

The problem with raw ThemeData

ThemeData is designed around Material Design. If your design system diverges from Material — and most product apps do — you end up fighting it. Common issues:

  • ColorScheme doesn't have a slot for your "warning" or "brand secondary" colour
  • TextTheme names (bodyMedium, labelLarge) don't map cleanly to your design system (paragraph, buttonLabel)
  • Component themes (ElevatedButtonThemeData) are inflexible for custom widget variants

The solution: extend ThemeData with a parallel AppTheme extension, or replace it entirely with a custom inherited widget.

Approach 1: ThemeExtension (Flutter built-in)

Flutter 3.x added ThemeExtension — a typed extension mechanism:

@immutable
class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
  const AppThemeExtension({
    required this.brandPrimary,
    required this.brandSecondary,
    required this.warning,
    required this.success,
    required this.cardShadow,
    required this.borderRadius,
  });

  final Color brandPrimary;
  final Color brandSecondary;
  final Color warning;
  final Color success;
  final BoxShadow cardShadow;
  final BorderRadius borderRadius;

  @override
  AppThemeExtension copyWith({
    Color? brandPrimary,
    Color? brandSecondary,
    Color? warning,
    Color? success,
    BoxShadow? cardShadow,
    BorderRadius? borderRadius,
  }) =>
      AppThemeExtension(
        brandPrimary: brandPrimary ?? this.brandPrimary,
        brandSecondary: brandSecondary ?? this.brandSecondary,
        warning: warning ?? this.warning,
        success: success ?? this.success,
        cardShadow: cardShadow ?? this.cardShadow,
        borderRadius: borderRadius ?? this.borderRadius,
      );

  @override
  AppThemeExtension lerp(AppThemeExtension? other, double t) {
    if (other is! AppThemeExtension) return this;
    return AppThemeExtension(
      brandPrimary: Color.lerp(brandPrimary, other.brandPrimary, t)!,
      brandSecondary: Color.lerp(brandSecondary, other.brandSecondary, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
      success: Color.lerp(success, other.success, t)!,
      cardShadow: BoxShadow.lerp(cardShadow, other.cardShadow, t)!,
      borderRadius: BorderRadius.lerp(borderRadius, other.borderRadius, t)!,
    );
  }
}

Register it in ThemeData:

ThemeData buildTheme() => ThemeData(
  useMaterial3: true,
  extensions: [
    const AppThemeExtension(
      brandPrimary: Color(0xFF0A1628),
      brandSecondary: Color(0xFF2979FF),
      warning: Color(0xFFF57C00),
      success: Color(0xFF2E7D32),
      cardShadow: BoxShadow(
        color: Color(0x1A000000),
        blurRadius: 8,
        offset: Offset(0, 2),
      ),
      borderRadius: BorderRadius.all(Radius.circular(12)),
    ),
  ],
);

Access it anywhere:

final ext = Theme.of(context).extension<AppThemeExtension>()!;
Container(
  decoration: BoxDecoration(
    color: ext.brandPrimary,
    borderRadius: ext.borderRadius,
    boxShadow: [ext.cardShadow],
  ),
);

Approach 2: Custom InheritedWidget (full control)

For white-label apps where themes switch per tenant:

class AppTheme extends InheritedWidget {
  const AppTheme({super.key, required this.data, required super.child});

  final AppThemeData data;

  static AppThemeData of(BuildContext context) {
    final theme = context.dependOnInheritedWidgetOfExactType<AppTheme>();
    assert(theme != null, 'AppTheme not found in widget tree');
    return theme!.data;
  }

  @override
  bool updateShouldNotify(AppTheme old) => data != old.data;
}

@immutable
class AppThemeData {
  const AppThemeData({
    required this.colors,
    required this.typography,
    required this.spacing,
  });

  final AppColorScheme colors;
  final AppTypography typography;
  final AppSpacing spacing;
}

Usage:

final theme = AppTheme.of(context);
Text('Hello', style: theme.typography.heading);

Switching themes at runtime

class _AppState extends State<App> {
  AppThemeData _theme = AppThemes.light;

  @override
  Widget build(BuildContext context) {
    return AppTheme(
      data: _theme,
      child: MaterialApp(
        home: Builder(builder: (ctx) => HomeScreen(
          onToggleTheme: () => setState(() {
            _theme = _theme == AppThemes.light
                ? AppThemes.dark
                : AppThemes.light;
          }),
        )),
      ),
    );
  }
}

Per-flavor themes

For multi-brand or flavor-based theming:

class AppThemes {
  static AppThemeData forFlavor(String flavor) => switch (flavor) {
    'brand_a' => AppThemeData(
      colors: AppColorScheme(
        primary: const Color(0xFF003087),
        accent: const Color(0xFFFFD700),
      ),
      typography: AppTypography.defaultTypography,
      spacing: AppSpacing.defaultSpacing,
    ),
    'brand_b' => AppThemeData(
      colors: AppColorScheme(
        primary: const Color(0xFFB5121B),
        accent: const Color(0xFFFFFFFF),
      ),
      typography: AppTypography.defaultTypography,
      spacing: AppSpacing.defaultSpacing,
    ),
    _ => _defaultTheme,
  };
}

Animating theme changes

ThemeData interpolation is built into MaterialApp via AnimatedTheme — theme switches automatically animate when you provide a new theme:

MaterialApp(
  theme: isDark ? darkTheme : lightTheme,
  // Theme changes animate automatically
)

For custom InheritedWidget themes, wrap with AnimatedSwitcher or use ThemeExtension.lerp manually.

Common pitfalls

Forgetting lerp in ThemeExtension. Without proper lerp implementations, animated theme transitions look broken — colours jump instead of transitioning.

Accessing theme outside MaterialApp. Theme.of(context) returns the default theme if called above MaterialApp in the widget tree. Always place theme-reading widgets below MaterialApp.

Too many theme variants. Start with light + dark. Add brand variants only when you have a real white-label requirement, not speculatively.

Theme and state mixed together. Theme is configuration, not state. Don't store user preferences (font size, high-contrast mode) in the theme object — store them in a settings provider and derive the theme from them.

Sign in to like, dislike, or report.

Building a custom theme system in Flutter — ANN Tech