# 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.

```dart
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)

```dart
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.

```dart
@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:

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

---

## Quick Theme Switching UI (With Animation)

```dart
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`:

```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`:

```dart
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:

```dart
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:**

```dart
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:**

```dart
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:

```dart
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:

```dart
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:

```dart
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 animation** — `AnimatedTheme` 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.

