Skip to main content

Command Palette

Search for a command to run...

🎨 Flutter Theme Magic: Light, Dark, System — and Your Own Beautiful Brand

Updated
7 min read
🎨 Flutter Theme Magic: Light, Dark, System — and Your Own Beautiful Brand

🌟 A tiny story to start

Meet Lumi (loves light mode) and Nyx (prefers dark). They share one Flutter app.
Lumi wants bright cards and cheerful blues; Nyx wants calm grays and a cozy dark background.
Your job as the developer? One app, two moods, zero duplication.
That’s what theming is for.

We’ll build a clean, modern theme system that:

  • switches between light / dark / system modes,

  • centralizes colors, typography, shapes, paddings,

  • supports quick toggles and persistence, and

  • scales with Theme Extensions for pro-level customization.


🧠 Theme 101: What actually is a Theme?

  • ThemeData: the big bag of app visuals (colors, typography, shapes, etc.).

  • ColorScheme: the modern, Material 3–style color palette. Prefer this!

  • ThemeMode: chooses light, dark, or system.

  • Theme.of(context): read current theme.

  • AnimatedTheme: smoothly animate theme changes.

  • ThemeExtension: add your own themeable tokens (e.g., brand spacing).


🏗️ Project skeleton

We’ll use Material 3 with a ColorScheme, add a quick toggle, and persist the 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,
      ),
    );
  }
}

What’s happening here

  • We load SharedPreferences to restore the last chosen ThemeMode.

  • MaterialApp receives three knobs: theme, darkTheme, and themeMode.

  • Calling _setMode updates UI and persists the choice.


🎯 Your design tokens: ColorScheme-first (Material 3)

class AppTheme {
  // Brand seeds
  static const _seed = Color(0xFF4F46E5); // Indigo-ish
  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,
    );
  }
}

What’s happening

  • ColorScheme.fromSeed gives you a complete palette (primary, secondary, surface, background, etc.) for light and dark.

  • We tune AppBar, Card, and Text with that scheme.

  • We already hint at ThemeExtension for spacing (next section).


➕ Pro move: ThemeExtension for your brand tokens

Add anything you want to theme (spacing, radii, shadows) — strongly typed.

@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>()!;
}

Usage in widgets

Padding(
  padding: EdgeInsets.all(context.space.lg),
  child: const Text('Hello, tokens 👋'),
)

⚡ 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 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),
      ),
    );
  }
}

What’s happening

  • A menu for explicit selection and a FAB for quick cycling.

  • AnimatedTheme gives a smooth fade between palettes.


🖍️ Custom fonts & typography (bonus polish)

  1. Add font to pubspec.yaml (short form):
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
  1. Apply to ThemeData:
static TextTheme _textTheme(ColorScheme scheme) {
  final base = Typography.material2021().black;
  return base.apply(
    fontFamily: 'Inter',
    bodyColor: scheme.onBackground,
    displayColor: scheme.onBackground,
  );
}

Tip: If you need a different type scale in dark mode, make a second builder.


🖥️ Respect the system theme automatically

  • Using ThemeMode.system will follow iOS/Android/Mac/Windows settings.

  • You can also query current platform brightness:

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

Don’t mix manual toggles with this unless you persist the choice and clearly indicate what’s active.


🧪 Using theme correctly inside widgets

✅ Do

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

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

❌ Avoid

// Hardcoding breaks dark mode and brand consistency:
Container(color: Colors.white);
Text('Hello', style: TextStyle(color: Colors.black));

🧯 Status bar & system overlays (important detail)

To guarantee readable status-bar icons per theme:

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

Or wrap a custom area with AnnotatedRegion<SystemUiOverlayStyle>.


🧩 Theming Cupertino widgets (when needed)

If you use Cupertino widgets, wrap them with CupertinoTheme or rely on MaterialApp bridging:

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

🗂️ Multiple brand themes (e.g., “Blue” vs “Green”)

Create multiple ColorSchemes and switch at runtime exactly like ThemeMode, or store them as theme presets:

enum Brand { indigo, green }

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

Now you can toggle brandThemes[currentBrand]! with the same persistence approach.


🧱 Common pitfalls & fixes

  • Hardcoded colors → always pull from Theme.of(context).colorScheme.

  • Forgetting darkTheme → light theme bleeds into dark devices. Provide both.

  • Using ThemeMode.dark without user control → frustrates users. Offer a toggle.

  • Text contrast issues → rely on onSurface, onBackground, etc., from ColorScheme.

  • No animation → use AnimatedTheme for smoothness (tiny but delightful).

  • Global tokens scattered → centralize in ThemeExtensions.


🧭 Checklist: “Done right” theming

  • Material 3 with ColorScheme.

  • Both theme and darkTheme.

  • themeMode persisted (SharedPreferences fine).

  • Quick toggle + system option.

  • Fonts applied via TextTheme.

  • Brand tokens via ThemeExtension.

  • Animated transitions.

  • Correct status bar icon colors.

  • No hardcoded colors in widgets.


🧠 Key takeaways

  • ThemeMode controls which theme: light/dark/system.

  • ColorScheme is the modern source of truth — build from a seed.

  • ThemeExtension lets you add your own design tokens (spacing, radii, shadows…).

  • Persist user choice and animate changes for polish.

  • Always read colors/typography from Theme — not hardcoded values.