Skip to main content

Command Palette

Search for a command to run...

Flutter Theme Guide: Light Mode, Dark Mode, System Theme and Custom Brand Colors

Updated
8 min read
Flutter Theme Guide: Light Mode, Dark Mode, System Theme and Custom Brand Colors

A Quick Story to Set the Scene

Meet Lumi and Nyx. They share one Flutter app. Lumi prefers light mode — bright cards, cheerful blues, a UI that feels open. Nyx wants the opposite — calm grays, a dark background, something easier on the eyes at night.

Your job as the developer? One app, two moods, zero duplication.

That's what theming is for. In this article, we'll build a clean, production-ready theme system that:

  • switches between light, dark, and system modes,
  • centralizes colors, typography, shapes, and spacing,
  • supports quick toggles and persistence, and
  • scales with Theme Extensions for custom brand tokens.

Theme 101: What Actually Is a Theme?

Before writing any code, it's worth getting the terminology straight:

  • ThemeData — the master container for all visual settings: colors, typography, shapes, component styles.
  • ColorScheme — the Material 3 color palette. Always prefer this over manual color assignments.
  • ThemeMode — tells Flutter which theme to use: light, dark, or system.
  • Theme.of(context) — how you read the active theme from inside any widget.
  • AnimatedTheme — wraps widgets and animates smoothly when the theme changes.
  • ThemeExtension — lets you attach custom design tokens (like spacing or border radii) directly to the theme.

Project Skeleton

We'll start with Material 3, wire up a ColorScheme, add a quick toggle, and persist the user's choice.

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  final saved = prefs.getString('themeMode') ?? 'system';
  final initialMode = _parseThemeMode(saved);

  runApp(AppRoot(initialMode: initialMode, prefs: prefs));
}

ThemeMode _parseThemeMode(String s) => switch (s) {
  'light' => ThemeMode.light,
  'dark' => ThemeMode.dark,
  _ => ThemeMode.system,
};

class AppRoot extends StatefulWidget {
  const AppRoot({super.key, required this.initialMode, required this.prefs});
  final ThemeMode initialMode;
  final SharedPreferences prefs;

  @override
  State<AppRoot> createState() => _AppRootState();
}

class _AppRootState extends State<AppRoot> {
  late ThemeMode _mode = widget.initialMode;

  Future<void> _setMode(ThemeMode m) async {
    setState(() => _mode = m);
    await widget.prefs.setString('themeMode', switch (m) {
      ThemeMode.light => 'light',
      ThemeMode.dark => 'dark',
      ThemeMode.system => 'system',
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Lumi & Nyx',
      debugShowCheckedModeBanner: false,
      themeMode: _mode,
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      home: HomeScreen(
        mode: _mode,
        onChangeMode: _setMode,
      ),
    );
  }
}

A few things worth noting here:

  • We read SharedPreferences on startup to restore the user's last chosen mode.
  • MaterialApp takes three separate knobs: theme, darkTheme, and themeMode.
  • Calling _setMode triggers a rebuild and also writes the choice to disk.

Your Design Tokens: ColorScheme-First (Material 3)

class AppTheme {
  static const _seed = Color(0xFF4F46E5);
  static const _error = Color(0xFFB00020);

  static final ColorScheme _lightScheme = ColorScheme.fromSeed(
    seedColor: _seed,
    brightness: Brightness.light,
    error: _error,
  );

  static final ColorScheme _darkScheme = ColorScheme.fromSeed(
    seedColor: _seed,
    brightness: Brightness.dark,
    error: _error,
  );

  static ThemeData get light => ThemeData(
        useMaterial3: true,
        colorScheme: _lightScheme,
        scaffoldBackgroundColor: _lightScheme.background,
        appBarTheme: AppBarTheme(
          backgroundColor: _lightScheme.surface,
          foregroundColor: _lightScheme.onSurface,
          elevation: 0,
          centerTitle: false,
        ),
        textTheme: _textTheme(_lightScheme),
        cardTheme: CardTheme(
          elevation: 0,
          margin: const EdgeInsets.all(12),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
          ),
        ),
        extensions: const <ThemeExtension<dynamic>>[
          AppSpacing.compact,
        ],
      );

  static ThemeData get dark => ThemeData(
        useMaterial3: true,
        colorScheme: _darkScheme,
        scaffoldBackgroundColor: _darkScheme.background,
        appBarTheme: AppBarTheme(
          backgroundColor: _darkScheme.surface,
          foregroundColor: _darkScheme.onSurface,
          elevation: 0,
          centerTitle: false,
        ),
        textTheme: _textTheme(_darkScheme),
        cardTheme: CardTheme(
          elevation: 0,
          margin: const EdgeInsets.all(12),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
          ),
        ),
        extensions: const <ThemeExtension<dynamic>>[
          AppSpacing.compact,
        ],
      );

  static TextTheme _textTheme(ColorScheme scheme) {
    return Typography.material2021().black.apply(
      bodyColor: scheme.onBackground,
      displayColor: scheme.onBackground,
    );
  }
}

ColorScheme.fromSeed does a lot of heavy lifting — it derives a full, harmonious palette (primary, secondary, surface, background, and their "on" counterparts) from a single brand color, for both light and dark. You wire it in once and everything else just works.


Pro Move: ThemeExtension for Brand Tokens

The built-in ThemeData covers most cases, but real apps often need things like consistent spacing values, custom border radii, or brand-specific shadow levels. ThemeExtension is the right way to handle this — it keeps your tokens in the theme tree, not scattered as constants across the codebase.

@immutable
class AppSpacing extends ThemeExtension<AppSpacing> {
  final double xs, sm, md, lg, xl;
  const AppSpacing({
    required this.xs,
    required this.sm,
    required this.md,
    required this.lg,
    required this.xl,
  });

  static const compact = AppSpacing(xs: 4, sm: 8, md: 12, lg: 16, xl: 24);

  @override
  AppSpacing copyWith({double? xs, double? sm, double? md, double? lg, double? xl}) {
    return AppSpacing(
      xs: xs ?? this.xs,
      sm: sm ?? this.sm,
      md: md ?? this.md,
      lg: lg ?? this.lg,
      xl: xl ?? this.xl,
    );
  }

  @override
  AppSpacing lerp(ThemeExtension<AppSpacing>? other, double t) {
    if (other is! AppSpacing) return this;
    double lerpDouble(double a, double b) => a + (b - a) * t;
    return AppSpacing(
      xs: lerpDouble(xs, other.xs),
      sm: lerpDouble(sm, other.sm),
      md: lerpDouble(md, other.md),
      lg: lerpDouble(lg, other.lg),
      xl: lerpDouble(xl, other.xl),
    );
  }
}

extension SpacingX on BuildContext {
  AppSpacing get space => Theme.of(this).extension<AppSpacing>()!;
}

Using it in widgets is clean and readable:

Padding(
  padding: EdgeInsets.all(context.space.lg),
  child: const Text('Consistent spacing everywhere.'),
)

Quick Theme Switching UI (With Animation)

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key, required this.mode, required this.onChangeMode});
  final ThemeMode mode;
  final ValueChanged<ThemeMode> onChangeMode;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Lumi & Nyx'),
        actions: [
          PopupMenuButton<ThemeMode>(
            icon: const Icon(Icons.light_mode),
            initialValue: mode,
            onSelected: onChangeMode,
            itemBuilder: (context) => const [
              PopupMenuItem(value: ThemeMode.system, child: Text('System')),
              PopupMenuItem(value: ThemeMode.light, child: Text('Light')),
              PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')),
            ],
          ),
        ],
      ),
      body: AnimatedTheme(
        duration: const Duration(milliseconds: 250),
        data: Theme.of(context),
        child: Center(
          child: Card(
            color: scheme.surface,
            child: Padding(
              padding: EdgeInsets.all(context.space.xl),
              child: Text(
                'This card adapts to the active theme.',
                style: Theme.of(context).textTheme.titleLarge,
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () {
          final next = switch (mode) {
            ThemeMode.system => ThemeMode.light,
            ThemeMode.light => ThemeMode.dark,
            ThemeMode.dark => ThemeMode.system,
          };
          onChangeMode(next);
        },
        label: Text('Mode: ${mode.name}'),
        icon: const Icon(Icons.auto_mode),
      ),
    );
  }
}

The FAB cycles through modes quickly; the menu gives the user explicit control. AnimatedTheme handles the transition so the switch doesn't feel abrupt.


Custom Fonts and Typography

Add your font to pubspec.yaml:

flutter:
  uses-material-design: true
  fonts:
    - family: Inter
      fonts:
        - asset: assets/fonts/Inter-Regular.ttf
        - asset: assets/fonts/Inter-Medium.ttf
          weight: 500
        - asset: assets/fonts/Inter-SemiBold.ttf
          weight: 600

Then apply it in your AppTheme:

static TextTheme _textTheme(ColorScheme scheme) {
  final base = Typography.material2021().black;
  return base.apply(
    fontFamily: 'Inter',
    bodyColor: scheme.onBackground,
    displayColor: scheme.onBackground,
  );
}

If you want a different type scale in dark mode, you can create a separate builder for it.


Respecting the System Theme

Using ThemeMode.system is the right default — it automatically follows the OS-level setting on Android, iOS, macOS, and Windows. If you ever need to query it manually:

final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;

One practical note: if you're offering a manual override toggle, make sure you're also persisting the choice. Otherwise users will find their preference reset every time they launch the app.


Reading Theme Correctly Inside Widgets

The single most common theming mistake in Flutter codebases is hardcoding colors. It breaks dark mode and makes brand updates painful.

Do this:

final cs = Theme.of(context).colorScheme;

Container(
  decoration: BoxDecoration(
    color: cs.surface,
    borderRadius: BorderRadius.circular(16),
  ),
  child: Text('Hello', style: Theme.of(context).textTheme.bodyLarge),
);

Avoid this:

Container(color: Colors.white);
Text('Hello', style: TextStyle(color: Colors.black));

Hardcoded colors are invisible to the theme system. They'll look wrong in dark mode and require manual updates whenever your brand changes.


Status Bar and System Overlays

This detail is easy to overlook but visible to every user. Make sure your status bar icons stay readable against both themes:

AppBar(
  systemOverlayStyle: Theme.of(context).brightness == Brightness.dark
      ? SystemUiOverlayStyle.light
      : SystemUiOverlayStyle.dark,
);

For custom areas without an AppBar, wrap with AnnotatedRegion<SystemUiOverlayStyle>.


Theming Cupertino Widgets

If your app uses Cupertino-style components alongside Material ones, pass the current theme through:

CupertinoTheme(
  data: CupertinoThemeData(
    brightness: Theme.of(context).brightness,
    primaryColor: Theme.of(context).colorScheme.primary,
  ),
  child: const CupertinoButton(child: Text('iOS style'), onPressed: null),
);

Multiple Brand Themes

Some apps need more than light and dark — think "Blue plan" vs "Green plan", or white-labeled variants. You can handle this with the same approach:

enum Brand { indigo, green }

final brandThemes = <Brand, ThemeData>{
  Brand.indigo: AppTheme.light,
  Brand.green: AppTheme.light.copyWith(
    colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0EA5A5)),
  ),
};

Swap brandThemes[currentBrand]! the same way you'd swap ThemeMode, and persist the choice.


Common Pitfalls

A few things that come up repeatedly in code reviews:

  • Hardcoded colors — the most frequent issue. Always pull from Theme.of(context).colorScheme.
  • Missing darkTheme — if you only set theme, your app will show the light theme on dark-mode devices.
  • No persistence — users expect their theme preference to survive an app restart.
  • Text contrast issues — use onSurface, onBackground, and similar "on" colors from ColorScheme instead of guessing.
  • No animationAnimatedTheme is a small addition that makes theme switches feel intentional rather than jarring.
  • Scattered global tokens — if you're defining spacing or border radii as constants spread across files, move them into a ThemeExtension.

A Quick Checklist Before Shipping

  • Material 3 enabled with useMaterial3: true
  • Both theme and darkTheme provided
  • themeMode persisted across sessions
  • Quick toggle available, with system mode as an option
  • Custom fonts applied via TextTheme
  • Brand-specific tokens in a ThemeExtension
  • Animated transitions on theme change
  • Status bar icon colors correct per theme
  • No hardcoded colors anywhere in widgets

Key Takeaways

ThemeMode controls which theme is active. ColorScheme is your source of truth for colors — build it from a seed and let Flutter derive the rest. ThemeExtension is the right place for custom design tokens like spacing and radii. Persist the user's preference and animate transitions. And keep hardcoded colors out of your widgets entirely — your future self will thank you.