← Back to blog
Flutter Theming Foundations · Part 5 of 6
August 28, 20268 min read

Light and Dark Mode — The Right Way to Implement Both

FlutterDartTheming

Light and Dark Mode

This is Part 5 of the Flutter Theming Foundations series — the payoff. Everything we built (a ThemeData driven by a ColorScheme and a TextTheme) was quietly setting up this: dark mode that's two themes and a toggle, not a rewrite.

Back in Part 1 we said dark mode is the scenario that ends the hardcoding debate. Here's why it's easy when you've themed properly — and impossible when you haven't.


The three ingredients

MaterialApp has exactly the API you need built in:

MaterialApp(
  theme: lightTheme,         // used when in light mode
  darkTheme: darkTheme,      // used when in dark mode
  themeMode: ThemeMode.system, // which one to show right now
  home: const HomePage(),
)
  • theme — the light ThemeData.
  • darkTheme — the dark ThemeData.
  • themeMode — a ThemeMode enum telling Flutter which to use: light, dark, or system.

Analogy — sunglasses. You build the world once (your widgets). theme and darkTheme are two pairs of glasses — clear and tinted. themeMode is the hand that decides which pair is on. The world doesn't change; the lens does. Your widgets never know which lens is active because they only ever ask the theme "what color here?"

That last sentence is the whole trick: because widgets read colorScheme/textTheme (Parts 3–4) instead of hardcoding, swapping the lens re-colors everything for free.


Building the two themes from one seed

The cleanest pattern: one seed color, two brightness-matched schemes, a shared text theme. This keeps light and dark on brand (same hue) while each is tuned for its background.

const _seed = Color(0xFF6750A4);

ThemeData _themeFor(Brightness brightness) {
  final scheme = ColorScheme.fromSeed(
    seedColor: _seed,
    brightness: brightness, // the only thing that differs
  );
  return ThemeData(
    colorScheme: scheme,
    textTheme: _appTextTheme,            // same type scale in both
    appBarTheme: AppBarTheme(
      backgroundColor: scheme.surface,    // pulled from the scheme → adapts
      foregroundColor: scheme.onSurface,
      elevation: 0,
    ),
  );
}

final lightTheme = _themeFor(Brightness.light);
final darkTheme  = _themeFor(Brightness.dark);
MaterialApp(
  theme: lightTheme,
  darkTheme: darkTheme,
  themeMode: ThemeMode.system,
  home: const HomePage(),
)

Note what we didn't do: we didn't write two sets of colors. The AppBar pulls scheme.surface/scheme.onSurface, so it's dark-correct in the dark theme automatically. That's the dividend of building on roles.

Best practice: keep the seed and text scale shared; let brightness be the only deliberate difference between the two ColorSchemes. Don't hand-author a separate dark palette unless a brand demands it.


ThemeMode: light, dark, or follow the system

ThemeMode has three values:

| Value | Behavior | | --- | --- | | ThemeMode.light | Always use theme | | ThemeMode.dark | Always use darkTheme | | ThemeMode.system | Follow the OS setting (auto-switches when the user flips their phone to dark) |

ThemeMode.system is the respectful default — it honors the choice the user already made in their OS settings. A great app offers all three: "Light / Dark / System" in a settings screen.

Important: for ThemeMode.system to ever show dark, you must provide darkTheme. If darkTheme is null, Flutter falls back to theme even when the system is dark. Always supply both.

Holding themeMode as state and letting the user change it at runtime is the natural next step — and exactly where the Riverpod series picks up: a NotifierProvider<ThemeMode> driving this very property.


Why hardcoded colors break here (concretely)

Let's make Part 1's claim concrete. Suppose one widget hardcoded its colors:

// ❌ Looks fine in light mode, unreadable in dark mode.
Container(
  color: Colors.white,                 // stays white even in dark mode
  child: Text('Hello', style: TextStyle(color: Colors.black)),
)

Flip to dark and you get a glaring white card with black text in a sea of dark surfaces — it ignored the lens. The fix is to ask the theme:

// ✅ Adapts automatically.
Builder(builder: (context) {
  final scheme = Theme.of(context).colorScheme;
  return Container(
    color: scheme.surface,    // dark in dark mode
    child: Text('Hello', style: TextStyle(color: scheme.onSurface)),
  );
})

The dark-mode test: for any custom widget, ask "did I hardcode Colors.white/Colors.black/a hex background or text color?" If yes, it will break in dark mode. Replace with surface/onSurface (or the right role from Part 3).


Reacting to the current brightness in code

Sometimes a widget genuinely needs to know which mode is active (e.g. choosing a light vs dark logo asset). Read it from the theme, don't guess:

final isDark = Theme.of(context).brightness == Brightness.dark;
final logo = isDark ? 'assets/logo_light.png' : 'assets/logo_dark.png';

Theme.of(context).brightness reflects whichever theme is currently applied — so it's correct under light, dark, and system. Prefer this over MediaQuery.platformBrightnessOf(context) (which only reports the OS setting, ignoring an in-app override).


Don't forget the system chrome

A polished dark mode also updates the status-bar / navigation-bar icons so they're visible. Material does a lot of this for you via AppBar, but for full control:

import 'package:flutter/services.dart';

// e.g. set when building the AppBar or via AnnotatedRegion.
SystemChrome.setSystemUIOverlayStyle(
  isDark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
);

It's a small touch, but light status-bar icons on a dark app (and vice-versa) is the difference between "has dark mode" and "dark mode done right."


Putting it together

class App extends StatelessWidget {
  const App({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Themed App',
      theme: _themeFor(Brightness.light),
      darkTheme: _themeFor(Brightness.dark),
      themeMode: ThemeMode.system, // ← becomes state in the Riverpod series
      home: const HomePage(),
    );
  }
}

That's a complete, correct light/dark setup. The only thing it can't do yet is let the user pick a mode in-app and remember it — which is precisely the job of the sibling series.


Practice Challenges

Challenge 1 — Wire up both themes. Given lightTheme and darkTheme, configure MaterialApp to follow the OS setting.

Show solution
MaterialApp(
  theme: lightTheme,
  darkTheme: darkTheme,
  themeMode: ThemeMode.system,
  home: const HomePage(),
)

Challenge 2 — One seed, two schemes. Write a helper that returns a ThemeData for a given Brightness from a single seed.

Show solution
ThemeData themeFor(Brightness b) => ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: b),
);

brightness is the only difference; the seed stays shared.

Challenge 3 — Fix the broken card. Container(color: Colors.white, child: Text('Hi', style: TextStyle(color: Colors.black))) is unreadable in dark mode. Fix it.

Show solution
final scheme = Theme.of(context).colorScheme;
Container(
  color: scheme.surface,
  child: Text('Hi', style: TextStyle(color: scheme.onSurface)),
);

Challenge 4 — System gotcha. A dev sets themeMode: ThemeMode.system and theme: light but forgets darkTheme. What happens on a phone set to dark, and why?

Show solution

It stays light. With darkTheme null, Flutter has no dark theme to switch to, so ThemeMode.system falls back to theme. You must provide darkTheme for system/dark to take effect.

Challenge 5 — Pick the right brightness source. You want a logo that swaps with the effective app theme, even when the user has overridden the OS via an in-app toggle. Which API?

Show solution

Theme.of(context).brightness — it reflects the currently applied theme (respecting an in-app override), whereas MediaQuery.platformBrightnessOf(context) only reports the OS setting and would ignore the override.


Questions to test yourself

Q1 (basic). Which three MaterialApp properties implement light/dark mode?

Show answer

theme (light ThemeData), darkTheme (dark ThemeData), and themeMode (a ThemeMode: light/dark/system) selecting which to use.

Q2 (basic). What does ThemeMode.system do, and what must you provide for it to work?

Show answer

It follows the OS light/dark setting and auto-switches when the user changes it. You must provide both theme and darkTheme; without darkTheme it falls back to theme.

Q3 (intermediate). Why can you build both themes from a single seed color?

Show answer

ColorScheme.fromSeed takes a brightness argument, so the same seed yields a light scheme and a brightness-matched dark scheme. Sharing the seed keeps both on-brand (same hue); each is individually tuned for contrast.

Q4 (intermediate). Explain mechanically why themed widgets adapt to dark mode with no per-widget changes.

Show answer

Widgets read colors from Theme.of(context).colorScheme (an inherited theme). When themeMode selects the dark ThemeData, the inherited theme changes, dependents rebuild, and they re-read the dark scheme's values — so the same widget code produces dark-correct output.

Q5 (intermediate). A hardcoded color: Colors.white background breaks in dark mode. What's the role-based fix and why is it correct?

Show answer

Use Theme.of(context).colorScheme.surface (with onSurface for content). It's the semantic "component background" role, so it resolves to a light color in the light scheme and a dark color in the dark scheme automatically.

Q6 (advanced). When should you read Theme.of(context).brightness vs MediaQuery.platformBrightnessOf(context)?

Show answer

Use Theme.of(context).brightness when you want the effective app brightness, which respects an in-app themeMode override (light/dark forced by the user). Use MediaQuery.platformBrightnessOf(context) only when you specifically need the OS-level setting regardless of any in-app override (rare). For "match the current theme," the former is correct.


Wrapping up

  • Light/dark is theme + darkTheme + themeMode on MaterialApp — built in, no packages.
  • Build both ThemeDatas from one seed with different brightness; share the text scale. Let widgets pull from colorScheme/textTheme and they adapt for free.
  • ThemeMode.system is the respectful default — but you must supply darkTheme.
  • Hardcoded white/black/hex backgrounds are exactly what breaks; replace with surface/onSurface. Read the live mode via Theme.of(context).brightness.

That completes the Foundations. The last stop is the 100-question mastery bank — hints and solutions, plus 10 coding mini-exercises and a capstone. After that, the sibling series Mastering Riverpod: Theming makes this themeMode user-controlled, persistent, and reactive.