skip to content

In Flutter, how do ColorScheme.fromSeed, darkTheme and themeMode work together to give an app matching light and dark themes?

level: middleimportance: must knowfreq 55%

answer

  1. one seed, tonal palettes
  2. brightness parameter picks the tones
  3. dynamicSchemeVariant defaults to tonalSpot
  4. themeMode defaults to ThemeMode.system
  5. brightness mismatch assertion

basics

~20 s

ColorScheme.fromSeed derives every Material 3 color role from one seed color for a given brightness. Build a light and a dark ThemeData from the same seed, pass them as theme and darkTheme, and themeMode (default ThemeMode.system) picks one.

solid answer

~40 s

`ColorScheme.fromSeed(seedColor: ..., brightness: ...)` generates tonal palettes from the seed and assigns every role, including the `on*` and `surfaceContainer*` roles, with the tones Material 3 specifies for that brightness. I call it twice with the same seed, once with `Brightness.dark`, and wrap each in a `ThemeData`. `MaterialApp` takes them as `theme` and `darkTheme`; `themeMode` defaults to `ThemeMode.system`, so the platform's brightness setting decides, and `.light` or `.dark` force one for an in-app toggle. If `darkTheme` is null, dark mode falls back to `theme`. Two knobs matter: `dynamicSchemeVariant` (default `tonalSpot`, with `fidelity` keeping the seed closer to the brand) and `contrastLevel` from -1.0 to 1.0. The switch animates through `AnimatedTheme` over 200 ms by default.

go deeper

for a junior

Remember the three MaterialApp parameters: theme, darkTheme and themeMode, plus that fromSeed needs brightness: Brightness.dark for the dark scheme.

for a middle

Explain tonal palettes and tones, the tonalSpot default and why the seed is not the exact primary, the null darkTheme fallback and the brightness assertion.

for a senior

Show how you keep both modes from one seed, test both in golden or widget tests, and handle designers who need exact brand values without breaking on-role contrast.

for a principal

Discuss when generated schemes are good enough versus a hand-tuned scheme, and how contrastLevel and high-contrast themes fit an accessibility commitment.

## ColorScheme.fromSeed: a whole scheme from one color Material 3 does not ask you to pick thirty colors by hand. **`ColorScheme.fromSeed`** takes one `seedColor` and derives **tonal palettes** from it: a primary palette close to the seed, secondary and tertiary palettes shifted in hue or chroma, a neutral palette for surfaces and a neutral-variant palette for outlines. Each color role is then a specific **tone** (lightness step) from one of those palettes, and the tones chosen depend on the `brightness` argument: - With `Brightness.light`, `primary` is a darker tone and `onPrimary` a very light one; surfaces are light. - With `Brightness.dark`, the tones flip: `primary` is lighter, `onPrimary` darker, and surfaces are dark. Because both schemes come from the same palettes, light and dark read as the same brand. The factory also accepts overrides for any single role (`primary:`, `error:` and so on) when the brand requires an exact value. Its main parameters, as declared in Flutter 3.47: | Parameter | Default | Effect | |---|---|---| | `seedColor` | required | Source of every palette | | `brightness` | `Brightness.light` | Chooses light or dark tones | | `dynamicSchemeVariant` | `DynamicSchemeVariant.tonalSpot` | How far palettes stray from the seed | | `contrastLevel` | `0.0` | -1.0 (reduced) to 1.0 (high contrast) | `DynamicSchemeVariant` offers `tonalSpot`, `fidelity`, `monochrome`, `neutral`, `vibrant`, `expressive`, `content`, `rainbow` and `fruitSalad`. The default `tonalSpot` produces calm, lower-chroma colors, which surprises designers who expect their exact brand hue; `fidelity` or `content` keep `primary` much closer to the seed. `ThemeData` also has a shortcut, `colorSchemeSeed`, which calls `fromSeed` with the theme's brightness, but it cannot be combined with an explicit `colorScheme`. ## Wiring light and dark into MaterialApp `MaterialApp` has separate slots for each theme and a switch between them: ```dart import 'package:flutter/material.dart'; const Color retailerSeed = Color(0xFF7B1FA2); MaterialApp buildApp(ThemeMode mode) { return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: retailerSeed), ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: retailerSeed, brightness: Brightness.dark, ), ), themeMode: mode, home: const Placeholder(), ); } ``` How the active theme is chosen: 1. `themeMode` defaults to **`ThemeMode.system`**: `MaterialApp` reads the platform brightness from `MediaQuery` and uses `darkTheme` when it is dark. 2. `ThemeMode.light` and `ThemeMode.dark` force one theme regardless of the platform, which is what an in-app toggle sets. 3. If dark is wanted but **`darkTheme` is null**, `MaterialApp` falls back to `theme`, so the app stays light. 4. `highContrastTheme` and `highContrastDarkTheme` are optional extra slots used when the platform requests high contrast. The change is animated: `MaterialApp` wraps its content in `AnimatedTheme`, which interpolates the old and new `ThemeData` with `ThemeData.lerp` over `themeAnimationDuration`, 200 milliseconds by default, with `themeAnimationCurve` defaulting to `Curves.linear`. ## The brightness mismatch trap `ThemeData` records a `brightness`, and so does `ColorScheme`. If you write `ThemeData(brightness: Brightness.dark, colorScheme: ColorScheme.fromSeed(seedColor: seed))`, the scheme is light (the factory's default) while the theme claims dark, and `ThemeData` throws an assertion in debug mode: *ThemeData.brightness does not match ColorScheme.brightness*. The fix is to pass `brightness: Brightness.dark` to `fromSeed` and let `ThemeData` take its brightness from the scheme. ## Checking the result Generated schemes are consistent, but a designer still signs off on them. Two cheap checks help: render a small palette page listing every role of both schemes side by side, and run the app's key screens under both themes before release. The `on*` pairs are generated to be legible, but any color you override by hand (an exact `primary`, say) must be checked against its `on*` partner in both brightnesses, because the generator no longer guarantees that pair. ## Where to keep the user's choice `themeMode` is ordinary app state. A settings screen stores the choice, and the widget that builds `MaterialApp` rebuilds with the new `ThemeMode`. Persisting the choice across launches, and the state-management library holding it, belong to other topics; the theming point is that the toggle changes one enum and never rebuilds `ThemeData` by hand. ## Common mistakes - Building the dark theme with `ThemeData.dark()` and a different palette, so the two modes look like different brands. - Inverting light colors by hand instead of letting `fromSeed` choose dark tones. - Forgetting `darkTheme` and wondering why `ThemeMode.dark` shows the light theme. - Expecting `fromSeed` to put the seed's exact hex into `primary` with the default `tonalSpot` variant. - Hardcoding `Colors.black` text somewhere, which only shows up as a bug once dark mode ships. Assumed version: Flutter 3.47. `dynamicSchemeVariant` and `contrastLevel` are recent additions to `fromSeed`; older code that only passes `seedColor` and `brightness` still works unchanged.

  • In Flutter, why might a designer say ColorScheme.fromSeed 'changed the brand color', and what can you do about it?
    With the default `DynamicSchemeVariant.tonalSpot`, the primary palette is a moderated version of the seed, so `primary` is rarely the exact hex. Pass `dynamicSchemeVariant: DynamicSchemeVariant.fidelity` (or `content`) to keep it closer, or override `primary:` in `fromSeed` when an exact value is contractual, and check its `onPrimary` pairing.
  • In Flutter's MaterialApp, what happens when themeMode is ThemeMode.dark but darkTheme is null?
    `MaterialApp` falls back to `theme`, so the app renders with the light theme. Nothing fails; it simply looks like the toggle does nothing. Always supply `darkTheme` when you offer a dark option.
  • In Flutter, what does ThemeMode.system actually read to decide between theme and darkTheme?
    `MaterialApp` reads the platform brightness through `MediaQuery.platformBrightnessOf(context)` and picks `darkTheme` when it is `Brightness.dark`. Because it is a `MediaQuery` dependency, changing the OS setting while the app runs rebuilds `MaterialApp` and animates to the other theme.

saying these in an interview costs you the question

  • Expecting fromSeed with default settings to use the seed's exact hex as primary
  • Building the dark scheme by inverting the light colors by hand
  • Passing ThemeData(brightness: dark) with a fromSeed scheme left at light
  • Thinking themeMode defaults to ThemeMode.light
  • Assuming ThemeMode.dark works without supplying darkTheme