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 lightThemeData.darkTheme— the darkThemeData.themeMode— aThemeModeenum telling Flutter which to use:light,dark, orsystem.
Analogy — sunglasses. You build the world once (your widgets).
themeanddarkThemeare two pairs of glasses — clear and tinted.themeModeis 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
brightnessbe the only deliberate difference between the twoColorSchemes. 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.systemto ever show dark, you must providedarkTheme. IfdarkThemeis null, Flutter falls back tothemeeven 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 withsurface/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+themeModeonMaterialApp— built in, no packages. - Build both
ThemeDatas from one seed with differentbrightness; share the text scale. Let widgets pull fromcolorScheme/textThemeand they adapt for free. ThemeMode.systemis the respectful default — but you must supplydarkTheme.- Hardcoded
white/black/hex backgrounds are exactly what breaks; replace withsurface/onSurface. Read the live mode viaTheme.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.