In Flutter, how do ColorScheme.fromSeed, darkTheme and themeMode work together to give an app matching light and dark themes?
answer
- one seed, tonal palettes
- brightness parameter picks the tones
- dynamicSchemeVariant defaults to tonalSpot
- themeMode defaults to ThemeMode.system
- brightness mismatch assertion
basics
~20 sColorScheme.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
Remember the three MaterialApp parameters: theme, darkTheme and themeMode, plus that fromSeed needs brightness: Brightness.dark for the dark scheme.
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.
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.
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