Flutter Custom Theme System: The Complete, Scalable M3 Guide

Build a scalable custom theme system in Flutter with Material 3: tokens, ColorScheme, extensions, light/dark modes, component overrides, and tests.

ASOasis
9 min read
Flutter Custom Theme System: The Complete, Scalable M3 Guide

Image used for representation purposes only.

Overview

Flutter makes theme customization easy, but shipping a robust, scalable theme system takes more than flipping a few colors. In this guide, you’ll architect a token‑driven, Material 3–ready theming setup that supports light/dark modes, brand surfaces, component overrides, custom tokens (spacing, radii, shadows), runtime switching, and testability—without turning ThemeData into a 1,000‑line blob.

Goals and principles

  • Token first: define brand primitives once, map them into Material’s ColorScheme and TextTheme.
  • Single source of truth: centralize ThemeData creation to avoid drift.
  • Extensible: use ThemeExtensions for non‑Material tokens (e.g., spacing, elevation, radii).
  • Accessible: respect contrast, shape, and motion; test with goldens.
  • Maintainable: small, composable files and clear naming.

What makes up a Flutter theme?

  • ColorScheme: the canonical set of Material color roles (primary, onPrimary, surface, etc.).
  • Typography: TextTheme and font families, sizes, and weights.
  • Shape: corner radii and component shapes.
  • Component themes: per‑widget defaults (buttons, inputs, chips, app bars, etc.).
  • ThemeExtensions: your custom tokens (spacing scale, brand shadows, semantic colors).

Suggested project structure

Create a clear hierarchy under lib/theme/:

lib/
  theme/
    tokens/
      colors.dart         // seed colors, brand palette, semantic roles
      typography.dart     // font families, sizes, weights
      spacing.dart        // spacing scale
    extensions.dart       // ThemeExtensions like Spacing, Radii, Shadows
    schemes.dart          // light/dark ColorScheme factories
    components.dart       // component-level ThemeData overrides
    app_theme.dart        // public factory that builds ThemeData
    theme_controller.dart // runtime toggling of ThemeMode

This split keeps tokens/roles separate from component styling and runtime concerns.

Define design tokens

Start with design primitives that don’t depend on Flutter’s classes. These are stable, brand‑owned values.

// lib/theme/tokens/colors.dart
import 'package:flutter/material.dart';

class BrandColors {
  // Seed colors (Material 3 tonal palettes work well with fromSeed)
  static const Color seed = Color(0xFF4455EE);
  static const Color neutral = Color(0xFF191C1E);

  // Optional semantic roles (non-Material)
  static const Color success = Color(0xFF1DB954);
  static const Color warning = Color(0xFFF59E0B);
  static const Color danger  = Color(0xFFDC2626);
}
// lib/theme/tokens/typography.dart
import 'package:flutter/material.dart';

class BrandTypography {
  static const String displayFont = 'Inter';
  static const String bodyFont = 'Inter';

  static TextTheme textTheme = const TextTheme(
    displayLarge: TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.5),
    displayMedium: TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.25),
    titleLarge: TextStyle(fontWeight: FontWeight.w600),
    titleMedium: TextStyle(fontWeight: FontWeight.w600),
    bodyLarge: TextStyle(fontWeight: FontWeight.w400),
    bodyMedium: TextStyle(fontWeight: FontWeight.w400),
    labelLarge: TextStyle(fontWeight: FontWeight.w600),
  ).apply(fontFamily: bodyFont);
}
// lib/theme/tokens/spacing.dart
class SpacingScale {
  // 8pt base scale
  final double xs; // 4
  final double sm; // 8
  final double md; // 12
  final double lg; // 16
  final double xl; // 24
  final double xxl; // 32

  const SpacingScale({
    this.xs = 4,
    this.sm = 8,
    this.md = 12,
    this.lg = 16,
    this.xl = 24,
    this.xxl = 32,
  });
}

ThemeExtensions: carry non‑Material tokens

ThemeExtensions let you attach arbitrary, strongly typed tokens to the theme and access them via Theme.of(context).extension().

// lib/theme/extensions.dart
import 'package:flutter/material.dart';
import 'tokens/spacing.dart';

@immutable
class Spacing extends ThemeExtension<Spacing> {
  final SpacingScale scale;
  const Spacing(this.scale);

  @override
  Spacing copyWith({SpacingScale? scale}) => Spacing(scale ?? this.scale);

  @override
  ThemeExtension<Spacing> lerp(ThemeExtension<Spacing>? other, double t) {
    if (other is! Spacing) return this;
    return Spacing(SpacingScale(
      xs: lerpDouble(scale.xs, other.scale.xs, t)!,
      sm: lerpDouble(scale.sm, other.scale.sm, t)!,
      md: lerpDouble(scale.md, other.scale.md, t)!,
      lg: lerpDouble(scale.lg, other.scale.lg, t)!,
      xl: lerpDouble(scale.xl, other.scale.xl, t)!,
      xxl: lerpDouble(scale.xxl, other.scale.xxl, t)!,
    ));
  }
}

double? lerpDouble(num a, num b, double t) => a + (b - a) * t;

@immutable
class Radii extends ThemeExtension<Radii> {
  final double sm; // 8
  final double md; // 12
  final double lg; // 16
  const Radii({this.sm = 8, this.md = 12, this.lg = 16});

  @override
  Radii copyWith({double? sm, double? md, double? lg}) =>
      Radii(sm: sm ?? this.sm, md: md ?? this.md, lg: lg ?? this.lg);

  @override
  ThemeExtension<Radii> lerp(ThemeExtension<Radii>? other, double t) {
    if (other is! Radii) return this;
    return Radii(
      sm: lerpDouble(this.sm, other.sm, t)!,
      md: lerpDouble(this.md, other.md, t)!,
      lg: lerpDouble(this.lg, other.lg, t)!,
    );
  }
}

Usage in widgets:

final spacing = Theme.of(context).extension<Spacing>()!.scale;
SizedBox(height: spacing.lg);

Build ColorSchemes (light and dark)

Use Material 3’s tonal palettes via ColorScheme.fromSeed. It ensures cohesive surfaces, variants, and onX roles.

// lib/theme/schemes.dart
import 'package:flutter/material.dart';
import 'tokens/colors.dart';

class AppSchemes {
  static ColorScheme light = ColorScheme.fromSeed(
    seedColor: BrandColors.seed,
    brightness: Brightness.light,
  );

  static ColorScheme dark = ColorScheme.fromSeed(
    seedColor: BrandColors.seed,
    brightness: Brightness.dark,
  );

  // Optional: tweak for brand specifics (e.g., softer surfaces)
  static ColorScheme softenSurfaces(ColorScheme base) => base.copyWith(
    surface: Color.alphaBlend(base.primary.withOpacity(0.02), base.surface),
    surfaceContainerHighest:
        Color.alphaBlend(base.primary.withOpacity(0.04), base.surface),
  );
}

Tip: prefer using ColorScheme roles throughout your UI (e.g., colorScheme.primary) rather than ad‑hoc colors. This keeps contrast and dark mode consistent.

Compose ThemeData cleanly

Centralize ThemeData creation and apply extensions and component themes.

// lib/theme/components.dart
import 'package:flutter/material.dart';

ThemeData componentThemes(ThemeData base) {
  final cs = base.colorScheme;
  return base.copyWith(
    appBarTheme: AppBarTheme(
      backgroundColor: cs.surface,
      foregroundColor: cs.onSurface,
      elevation: 0,
      centerTitle: false,
      surfaceTintColor: cs.surfaceTint,
    ),
    elevatedButtonTheme: ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        backgroundColor: cs.primary,
        foregroundColor: cs.onPrimary,
        minimumSize: const Size(64, 44),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
    ),
    filledButtonTheme: FilledButtonThemeData(
      style: FilledButton.styleFrom(
        backgroundColor: cs.secondaryContainer,
        foregroundColor: cs.onSecondaryContainer,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      ),
    ),
    outlinedButtonTheme: OutlinedButtonThemeData(
      style: OutlinedButton.styleFrom(
        foregroundColor: cs.primary,
        side: BorderSide(color: cs.outline),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      ),
    ),
    inputDecorationTheme: InputDecorationTheme(
      filled: true,
      fillColor: cs.surfaceContainerHighest,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide(color: cs.outlineVariant),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide(color: cs.primary, width: 2),
      ),
      labelStyle: TextStyle(color: cs.onSurfaceVariant),
    ),
    cardTheme: CardTheme(
      color: cs.surface,
      elevation: 0,
      margin: const EdgeInsets.all(0),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
    ),
    chipTheme: ChipThemeData(
      backgroundColor: cs.surfaceContainerHighest,
      selectedColor: cs.secondaryContainer,
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      shape: StadiumBorder(side: BorderSide(color: cs.outlineVariant)),
      labelStyle: TextStyle(color: cs.onSurface),
    ),
    bottomSheetTheme: BottomSheetThemeData(
      backgroundColor: cs.surface,
      surfaceTintColor: cs.surfaceTint,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
      ),
    ),
    navigationBarTheme: NavigationBarThemeData(
      backgroundColor: cs.surface,
      indicatorColor: cs.secondaryContainer,
      labelTextStyle: MaterialStatePropertyAll(
        TextStyle(color: cs.onSurfaceVariant),
      ),
    ),
  );
}
// lib/theme/app_theme.dart
import 'package:flutter/material.dart';
import 'tokens/typography.dart';
import 'tokens/spacing.dart';
import 'extensions.dart';
import 'schemes.dart';
import 'components.dart';

class AppTheme {
  static ThemeData light() {
    final scheme = AppSchemes.softenSurfaces(AppSchemes.light);
    final base = ThemeData(
      useMaterial3: true,
      colorScheme: scheme,
      textTheme: BrandTypography.textTheme,
      visualDensity: VisualDensity.adaptivePlatformDensity,
      // Global shape defaults can be set via ThemeData.shapeScheme in newer SDKs
    ).copyWith(
      extensions: const <ThemeExtension<dynamic>>[
        Spacing(SpacingScale()),
        Radii(sm: 8, md: 12, lg: 16),
      ],
    );
    return componentThemes(base);
  }

  static ThemeData dark() {
    final scheme = AppSchemes.softenSurfaces(AppSchemes.dark);
    final base = ThemeData(
      useMaterial3: true,
      colorScheme: scheme,
      textTheme: BrandTypography.textTheme,
      visualDensity: VisualDensity.adaptivePlatformDensity,
    ).copyWith(
      extensions: const <ThemeExtension<dynamic>>[
        Spacing(SpacingScale()),
        Radii(sm: 8, md: 12, lg: 16),
      ],
    );
    return componentThemes(base);
  }
}

Runtime switching with ThemeMode

Provide a controller that stores and toggles ThemeMode. You can wire it to Provider, Riverpod, or a simple ValueListenable.

// lib/theme/theme_controller.dart
import 'package:flutter/material.dart';

class ThemeController extends ChangeNotifier {
  ThemeMode _mode = ThemeMode.system;
  ThemeMode get mode => _mode;

  void set(ThemeMode mode) {
    if (_mode == mode) return;
    _mode = mode;
    notifyListeners();
  }

  void toggle() {
    switch (_mode) {
      case ThemeMode.light: set(ThemeMode.dark); break;
      case ThemeMode.dark: set(ThemeMode.light); break;
      case ThemeMode.system: set(ThemeMode.light); break;
    }
  }
}

Use it in your app:

// main.dart
import 'package:flutter/material.dart';
import 'theme/app_theme.dart';
import 'theme/theme_controller.dart';

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({super.key});
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final controller = ThemeController();
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: controller,
      builder: (context, _) => MaterialApp(
        theme: AppTheme.light(),
        darkTheme: AppTheme.dark(),
        themeMode: controller.mode,
        home: Home(controller: controller),
      ),
    );
  }
}

class Home extends StatelessWidget {
  final ThemeController controller;
  const Home({super.key, required this.controller});
  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final spacing = Theme.of(context).extension<Spacing>()!.scale;
    return Scaffold(
      appBar: AppBar(title: const Text('Theming Demo')),
      body: Padding(
        padding: EdgeInsets.all(spacing.lg),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Primary', style: Theme.of(context).textTheme.titleLarge),
            SizedBox(height: spacing.md),
            Row(children: [
              _Swatch(cs.primary),
              _Swatch(cs.secondary),
              _Swatch(cs.tertiary),
              _Swatch(cs.error),
            ]),
            SizedBox(height: spacing.xl),
            FilledButton(
              onPressed: controller.toggle,
              child: const Text('Toggle theme'),
            ),
          ],
        ),
      ),
    );
  }
}

class _Swatch extends StatelessWidget {
  final Color color;
  const _Swatch(this.color);
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 40, height: 40, margin: const EdgeInsets.only(right: 8),
      decoration: BoxDecoration(
        color: color, borderRadius: BorderRadius.circular(12),
      ),
    );
  }
}

Dynamic color (Android 12+)

If you want the app to adopt the system’s dynamic colors on supported Android devices, add a small integration layer (e.g., using a dynamic color plugin). Blend the dynamic scheme into your ThemeData while falling back to your seed‑based scheme on unsupported platforms. Keep your component themes unchanged—they will automatically use the active ColorScheme.

Applying tokens in widgets

  • Colors: prefer Theme.of(context).colorScheme over Theme.of(context).primaryColor.
  • Typography: always use Theme.of(context).textTheme.* roles, not hard‑coded sizes.
  • Spacing: Theme.of(context).extension()!.scale to keep layout consistent.
  • Radii: Theme.of(context).extension() to shape custom containers.

Tip: Add convenience getters:

extension BuildContextX on BuildContext {
  ColorScheme get colors => Theme.of(this).colorScheme;
  TextTheme get text => Theme.of(this).textTheme;
  SpacingScale get spacing => Theme.of(this).extension<Spacing>()!.scale;
  Radii get radii => Theme.of(this).extension<Radii>()!;
}

Accessibility and contrast

  • Use onX colors for text/icons placed on colored surfaces (e.g., onPrimary on top of primary).
  • Favor filled inputs with surfaceContainerHighest for better contrast in dark mode.
  • Respect the minimum tap size (44×44) and readable text sizes (≥14sp for body text).
  • Test contrast with light and dark backgrounds; avoid low‑alpha text on surfaces.

Testing your theme

Golden tests prevent regression as tokens evolve.

// test/golden/theme_buttons_golden_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:your_app/theme/app_theme.dart';
import 'package:flutter/material.dart';

void main() {
  testGoldens('Buttons look correct in light & dark', (tester) async {
    final builder = GoldenBuilder.column()
      ..addScenario('Elevated', ElevatedButton(onPressed: (){}, child: const Text('Go')))
      ..addScenario('Filled', FilledButton(onPressed: (){}, child: const Text('Go')))
      ..addScenario('Outlined', OutlinedButton(onPressed: (){}, child: const Text('Go')));

    await tester.pumpWidgetBuilder(builder.build(), wrapper: materialAppWrapper(theme: AppTheme.light()));
    await screenMatchesGolden(tester, 'buttons_light');

    await tester.pumpWidgetBuilder(builder.build(), wrapper: materialAppWrapper(theme: AppTheme.dark()));
    await screenMatchesGolden(tester, 'buttons_dark');
  });
}

Migration tips (Material 2 → 3)

  • Set useMaterial3: true and migrate to ColorScheme usage; stop using primaryColor/primarySwatch.
  • Replace ButtonStyleButton.styleFrom calls with component themes (ElevatedButtonThemeData, FilledButtonThemeData, etc.) when you need global defaults.
  • Map legacy colors to ColorScheme roles; do not invent new roles unless unavoidable (then use ThemeExtensions).

Common pitfalls and fixes

  • Pitfall: hard‑coded colors in widgets. Fix: use ColorScheme roles.
  • Pitfall: component themes scattered across files. Fix: centralize in components.dart.
  • Pitfall: spacing magic numbers. Fix: use Spacing extension.
  • Pitfall: dark mode washed out. Fix: adjust surfaces via alpha blend (see softenSurfaces).
  • Pitfall: inconsistent radii. Fix: define Radii extension and reuse.

Performance notes

  • Many const widgets reduce rebuild work; prefer const constructors.
  • Theme.of(context) lookups are cheap; avoid passing colors as parameters when not necessary.
  • Keep ThemeData immutable during a frame; do not rebuild the entire theme for small tweaks—use ThemeExtensions for gradual evolution.

Checklist

  • Tokens defined for color, spacing, typography, radii.
  • ColorScheme derived from seed (and optionally dynamic color on Android).
  • Component themes cover key widgets.
  • ThemeExtensions applied and used in widgets.
  • ThemeMode switching wired with a controller.
  • Golden tests added for critical components.
  • Accessibility checked for contrast and sizes.

Conclusion

A strong theme system speeds up development, improves consistency, and keeps your app accessible as it scales. By grounding your Flutter app in tokens, ColorScheme, and ThemeExtensions—and by centralizing all theme logic—you get a future‑proof foundation that is easy to maintain and straightforward to evolve with new brands, features, or platforms.

Related Posts