Dark Mode in Flutter: ThemeData, ColorScheme.fromSeed, and System Themes
Build light and dark palettes from a single seed color, follow the system brightness with themeMode, and remember the user choice across launches — a practitioner guide to dark mode in Flutter.
Published on • October 12, 2026
AI Assistant

Dark mode rarely fails because Flutter lacks a switch. It fails because teams end up maintaining two unrelated palettes: a light one that grew with the app, and a dark one someone bolted on by grepping for Color(0xFF and darkening values by eye. Three months later every new widget is hardcoded to the light palette and dark mode silently rots.
Flutter solves this structurally. You define roles — primary, on-primary, surface, outline — once, and let the framework hand the right value to each widget depending on brightness. The whole mechanism is ThemeData, ColorScheme, and a single themeMode property on MaterialApp.
Theme data: the object that owns your look
MaterialApp.theme takes a ThemeData instance. If you don’t pass one, Flutter creates a default. Inside a theme, two properties do most of the work: colorScheme, which defines the colors, and textTheme, which defines text styling. As the ThemeData API docs put it, Material components “typically depend exclusively on the colorScheme and textTheme”, and both are guaranteed non-null.
You read those values with Theme.of(context), which walks up the widget tree and returns the nearest Theme. The theming cookbook spells out the precedence Flutter applies:
- Styles applied directly to the specific widget.
- Themes that override the immediate parent theme.
- The main app theme.
That order is what makes scoped theming possible without touching app-wide state:
Theme(
// Extend the parent instead of replacing it wholesale.
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
),
child: const FloatingActionButton(onPressed: null, child: Icon(Icons.add)),
),
One flag worth knowing: ThemeData.useMaterial3 is documented as “a temporary flag that can be used to opt-out of Material 3 features”. Material 3 is the baseline now, so you normally never set it — you configure the color scheme instead.
One seed, two schemes
ColorScheme.fromSeed is the piece that kills the double-maintenance problem. From the constructor docs:
Using the
seedColoras a starting point, a set of tonal palettes are constructed. By default, the tonal palettes are based on the Material 3 Color system and provide all of the ColorScheme colors. These colors are designed to work well together and meet contrast requirements for accessibility.
The signature you actually use:
ColorScheme.fromSeed({
required Color seedColor,
Brightness brightness = Brightness.light,
DynamicSchemeVariant dynamicSchemeVariant = DynamicSchemeVariant.tonalSpot,
double contrastLevel = 0.0,
// ...every role can be overridden individually
});
The palettes come from the material_color_utilities package, which the docs name as the generator. Because brightness is just a parameter, light and dark schemes come from the same seed:
const kSeed = Color(0xFF6750A4);
ThemeData lightTheme() => ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: kSeed),
);
ThemeData darkTheme() => ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: kSeed,
brightness: Brightness.dark,
),
);
MaterialApp(
theme: lightTheme(),
darkTheme: darkTheme(),
themeMode: ThemeMode.system,
home: const HomeScreen(),
);
Two details from the docs that save debugging time later:
- The seed may not appear in the output. “Given the nature of the algorithm, the
seedColormay not wind up as one of the ColorScheme colors.” If the defaulttonalSpotvariant feels washed out or too dark,DynamicSchemeVariant.fidelitybuilds palettes that match the seed color instead. - You can override any role. Every color parameter is optional and nullable; non-null values replace the generated color for that field only. That is how you fix one contrast problem without forking the palette.
The result is a ColorScheme of 45 roles — accent groups (primary, secondary, tertiary, plus -Fixed variants that stay identical across light and dark), neutral surface tones (surfaceDim, surfaceBright, surfaceContainerLowest … surfaceContainerHighest), and paired on* colors for content drawn on top of them. The class documentation notes that on colors “should have a contrast ratio with their matching colors of at least 4.5:1 in order to be readable”.
Following the system brightness
MaterialApp.themeMode decides which of your two themes wins. From the property docs: the default is ThemeMode.system; with system, MediaQuery.platformBrightnessOf being Brightness.light picks theme and Brightness.dark picks darkTheme (falling back to theme if darkTheme is null). Set ThemeMode.light or ThemeMode.dark and the system preference is ignored entirely.
If you’d rather build one theme that adapts instead of maintaining two, the ThemeData example does exactly that:
final ColorScheme colorScheme = ColorScheme.fromSeed(
brightness: MediaQuery.platformBrightnessOf(context),
seedColor: Colors.indigo,
);
Prefer MediaQuery.platformBrightnessOf(context) over reaching into MediaQuery.of(context).platformBrightness. The static helper exists precisely so the context “will only rebuild when this specific attribute changes, not when any attribute changes”. The same rule applies to MediaQuery.highContrastOf(context), which returns false when there is no MediaQuery ancestor.
Remembering the user’s choice
System-following is the default, but users expect a three-way override. The key-value persistence cookbook shows the shared_preferences flow: flutter pub add shared_preferences, then prefs.setString(...) to save and prefs.getString(...) to read. Setters update the in-memory map synchronously first and then persist to disk; getters return null when the key is missing.
A thin ValueNotifier keeps that out of your widget tree:
class ThemeController extends ValueNotifier<ThemeMode> {
ThemeController() : super(ThemeMode.system);
static const _key = 'themeMode';
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
value = ThemeMode.values.firstWhere(
(mode) => mode.name == prefs.getString(_key),
orElse: () => ThemeMode.system,
);
}
Future<void> set(ThemeMode mode) async {
value = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, mode.name);
}
}
Then rebuild only the root:
ValueListenableBuilder<ThemeMode>(
valueListenable: themeController,
builder: (context, mode, _) => MaterialApp(
theme: lightTheme(),
darkTheme: darkTheme(),
themeMode: mode,
home: const HomeScreen(),
),
);
ThemeMode values serialize cleanly to mode.name (system, light, dark), so there is no enum-index fragility in the stored string. Note the cookbook’s own caveats: only primitive types (int, double, bool, String, List<String>) are supported, it is not meant for large data, and persistence across restarts is not strictly guaranteed — treat a missing value as “system”.
Never hardcode a color inside a widget
Every Container(color: Color(0xFF6750A4)) you write is a dark-mode bug waiting to happen. Reach for roles instead:
Container(
color: Theme.of(context).colorScheme.primary,
child: Text(
'Label',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
),
);
Primary as background, onPrimary as foreground — the pair is generated together, so contrast holds in both brightnesses. Old names are being retired too: background, onBackground, and surfaceVariant are deprecated (after v3.18.0-0.1.pre) in favor of surface, onSurface, and surfaceContainerHighest, which means legacy code referencing them is already fighting the framework.
High contrast and dynamic color
A few hooks are worth wiring early:
MaterialAppacceptshighContrastThemeandhighContrastDarkThemealongsidethemeanddarkTheme, andMediaQuery.highContrastOf(context)tells you when the OS is asking for it.ColorScheme.highContrastLight()andColorScheme.highContrastDark()exist as ready-made high-contrast schemes.ColorScheme.fromSeedtakes acontrastLevelfrom-1.0(lowest) to1.0(highest), defaulting to0.0; the Material guidelines map medium and high contrast to0.5and1.0.- For brand extraction there is also
ColorScheme.fromImageProvider, which the ThemeData docs suggest alongsidefromSeed. - The
ColorSchemedocs point readers at the Material 3 color roles spec for how roles map onto components.
Gotchas
Theme.of(context)cannot see aThemewidget declared in the samebuildmethod. The ThemeData docs call this out explicitly: extract a child widget below theTheme, or insert aBuilderto create a freshBuildContext.- Passing only
thememeansthemeModedoes nothing. You needdarkThemeas well, otherwiseThemeMode.darksilently falls back totheme. ThemeData.light()andThemeData.dark()are SDK defaults, not your brand. Fine for prototypes; in production, build both from the same seed so the palettes stay related.- Don’t construct a fresh
ThemeDataon every rebuild. Hoist theme construction into top-level functions (as in the example above) so the instance is stable;Theme.ofitself is documented as “inexpensive, essentially just a single HashMap access” and you don’t want to undo that. MediaQuery.of(context)rebuilds on every MediaQuery change — size, padding, text scale, brightness. Use the...Ofhelpers for the one attribute you care about.- Don’t assume the seed color survives into the scheme. Verify the generated roles in both brightnesses rather than eyeballing the hex you passed in.
Summary
Dark mode in Flutter is not a second stylesheet; it is a second brightness fed through the same seed-driven color system. Build a light and a dark ColorScheme from one ColorScheme.fromSeed, hand both to MaterialApp with themeMode: ThemeMode.system, let a stored preference override it, and delete hardcoded colors wherever you find them. Do that once and every new widget is dark-mode-correct by construction.