You pass a ThemeData to MaterialApp(theme: ...), and every widget below reads it with Theme.of(context). Material widgets take their colours, text styles and shapes from it on their own, so most screens need no colour code at all.
**colorScheme**: the colour roles every Material component uses; build it with ColorScheme.fromSeed(seedColor: brand)
**textTheme and fontFamily**: named text styles such as titleLarge and bodyMedium, and one font for all of them
Component themes: appBarTheme, filledButtonTheme, inputDecorationTheme give every instance of a widget the same look without touching call sites
Reading it: Theme.of(context).colorScheme.primary; the call also subscribes the widget, so it rebuilds when the theme changes
Changing part of it: wrap a subtree in Theme(data: Theme.of(context).copyWith(...))
FlutterMiddle
Theming & Design Systems
ThemeData, ColorScheme.fromSeed, Material 3, ThemeExtension, custom fonts, dark mode
Questions closed
0 / 22
- 0Review
- 4Asked often
Questions
fromSeed treats your colour as a seed: it builds tonal palettes from its hue and takes every role (primary, surface, error…) from a fixed tone, so each colour and its on partner are readable by construction. That is why primary is close to your hex, not equal to it.
Build with roles: primary/onPrimary for the main action, surface/onSurface for the page and its text, onSurfaceVariant for secondary text, outlineVariant for dividers
Layers are surface tones: surfaceContainerLowest…surfaceContainerHighest separate cards and sheets instead of shadows
Dark is not inverted: the same seed with brightness: Brightness.dark gives a lighter primary, because a mid tone is unreadable on a dark surface
When the hex is contractual: dynamicSchemeVariant: DynamicSchemeVariant.fidelity keeps roles closer to the seed; for the exact value, copyWith(primary: brand) and check the contrast yourself
TextTheme is the set of named text styles inside ThemeData: 15 roles from displayLarge to labelSmall. Using a role means a typography change happens in one place instead of in every file that copied the same TextStyle.
Five families, three sizes each: display for hero numbers, headline for a screen's top line, title for app bars and cards, body for paragraphs, label for buttons and captions; each comes in Large, Medium and Small
Read the role: Theme.of(context).textTheme.titleMedium; for a one-off, copyWith on the role so the family, height and letter spacing stay
Change it centrally: textTheme.apply(fontFamily: 'Inter') for all roles, copyWith(titleLarge: ...) for one
Common mistake: baking a color: into a role: it stays the same grey in dark mode; let colour come from the colorScheme
Put the font files in the project, declare them under flutter: fonts: in pubspec.yaml, and use the family name in ThemeData(fontFamily: ...) or a TextStyle. Custom icons come either as an icon font used through IconData, or as SVG files drawn with the flutter_svg package.
One family, several files: each weight is its own asset: entry with a weight:; a weight without its own file falls back to the nearest one
Built-in icon sets: uses-material-design: true bundles the font behind Icons.*; CupertinoIcons need the cupertino_icons package
Icon font: generate one from the designer's SVGs (IcoMoon, FlutterIcon), declare it like any font, and expose static const IconData constants
**Keep IconData const**: release builds tree-shake icon fonts down to the glyphs you use, and a non-const IconData makes that build fail unless you pass --no-tree-shake-icons
**google_fonts**: convenient, but it downloads fonts at run time by default; bundle the files when typography must work offline
Material widgets follow Google's Material Design and are themed by ThemeData; Cupertino widgets copy iOS controls and are themed by CupertinoThemeData. Both are ordinary Flutter widgets, so you can mix them: a CupertinoSwitch inside a MaterialApp works.
Neither is native: Flutter paints both itself on every platform; Cupertino looks like iOS but does not call UIKit
**What MaterialApp gives Cupertino**: a CupertinoTheme derived from your ThemeData (brightness and primary colour) and the Cupertino localizations those widgets need
The reverse is weaker: under a bare CupertinoApp there is no Material theme, no MaterialLocalizations and no Material ancestor, so widgets like TextField and ListTile assert or render unstyled
Choosing: one design language per app, usually Material themed to the brand; reach for Cupertino controls or the .adaptive constructors where iOS users expect a native-looking control
Give MaterialApp both a theme and a darkTheme, and let themeMode choose: ThemeMode.system follows the device setting, light and dark force one. Build both themes with one function that takes a Brightness, so only the colour scheme differs between them.
One builder, two calls: ColorScheme.fromSeed(seedColor: brand, brightness: b) picks tones that read on dark; component themes, shapes and typography stay in one place, so a card radius cannot be right in light and wrong in dark
Brightness lives in the scheme: ThemeData takes it from colorScheme.brightness; widgets that hard-code Colors.white or Colors.black instead of onSurface are what break in dark mode
A user toggle: keep the chosen ThemeMode in state above MaterialApp (a ValueNotifier, a provider, a cubit) and persist it
**Restore it before runApp**: otherwise the first frame paints the system theme and the app visibly flips
MediaQuery.platformBrightnessOf reports the phone's system setting, not the theme the app is drawing. With themeMode: ThemeMode.light the app stays light while the phone is dark, so the widget picks the white logo and paints it on a light app bar.
Two different brightness values: platformBrightnessOf is the OS setting; Theme.of(context).brightness (the same as colorScheme.brightness) is what MaterialApp chose after applying themeMode. They agree only while themeMode is system
The fix: read Theme.of(context).brightness; the logo then follows the in-app switch and also a local Theme override, such as a dark promo section inside a light screen
Better still, no branch: keep the asset path in a ThemeExtension, or ship one monochrome SVG tinted with colorScheme.onSurface, so the logo cannot disagree with the theme
When the platform value is right: only where you need the OS setting itself, for example to show what "System" currently means on the settings screen
Catch it: a widget test with tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark and themeMode: ThemeMode.light that checks which asset the Image got; a grep for platformBrightness outside the theme setup finds the rest
Theme.of(context) looks up the tree from the context you pass, and this context belongs to App, which sits above the MaterialApp it builds. No Theme is found there, so the call returns ThemeData.fallback() with the baseline purple scheme.
**Read the theme below MaterialApp**: move the lookup into HomePage's build, or wrap home in a Builder, whose context is already under the theme
**MaterialApp(builder: ...): that callback also runs under the theme; app-wide wrappers that need it belong there
Same rule elsewhere**: Navigator.of and Scaffold.of fail the same way when the context is above the widget that provides them
**Not in initState**: Theme.of registers a dependency, which asserts there; call it in build or didChangeDependencies
The style is cached in a late final field, whose initializer runs once, on the first build. A theme change does rebuild the widget, because Theme.of registered a dependency, but build keeps reusing the light-theme TextStyle until the State is disposed.
Why the card updates and the text does not: Card reads the theme in its own build every time; _priceStyle was resolved once and is never asked again
The fix: derive the style inside build: Theme.of is a constant-time lookup and copyWith is cheap, so there is nothing worth caching
If a value really is expensive: recompute it in didChangeDependencies, which runs after every change of an inherited widget the State depends on; not in initState, where calling Theme.of fails an assertion
The same trap elsewhere: any X.of(context) result kept in a field: MediaQuery sizes, Localizations strings, DefaultTextStyle
Catch it: a widget test that pumps the app with ThemeMode.light, then ThemeMode.dark, and expects the Text colour to equal the dark scheme's primary; goldens in both themes show it too
Theme(data: ThemeData(...)) replaces the whole theme for the subtree instead of patching it: the new ThemeData has the default font, default component themes and no extensions. Derive the local theme from the inherited one with Theme.of(context).copyWith(...).
What was lost: fontFamily and textTheme, every component theme such as filledButtonTheme, the extensions list, and dark mode: this block stays light when the app is dark
The fix: data: theme.copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.red, brightness: theme.brightness)), where theme is Theme.of(context)
Narrower still: to recolour only the buttons, wrap the subtree in FilledButtonTheme(data: ...) and leave the rest of the theme alone
Why it compiles quietly: nothing is wrong with the new ThemeData itself; it is simply a complete theme that knows nothing about yours
WidgetStatePropertyAll(brand) answers brand for every state, disabled included, and a value passed in style beats both the component theme and the button's defaults, so the disabled colour from the defaults never gets a say.
Resolution order: the widget's style, then ThemeData.filledButtonTheme, then FilledButton's own defaults; property by property, the first non-null value wins
States: each property is a WidgetStateProperty resolved against the button's current WidgetStates: pressed, hovered, focused, disabled
The fix: FilledButton.styleFrom(backgroundColor: brand): it returns null for the disabled state, so that state falls through to the defaults; use WidgetStateProperty.resolveWith only for custom per-state logic
**style is a patch, not a replacement**: setting only backgroundColor keeps the theme's padding, shape and text style
Shared looks go in the theme: filledButtonTheme: FilledButtonThemeData(style: ...); a call-site style is for genuine one-offs
Flutter can only draw weights you declared as files in pubspec.yaml. It matches the requested FontWeight to the nearest declared file of that family, so a weight with no file of its own silently borrows a neighbour: with only 400 and 700 shipped, w500 renders as 400 and w600 as 700.
**One asset: per file, each with the right weight:: declare the weight the file really is (a Medium file as 500) and keep it consistent with the weight stored in the font file
Keep every weight under one family:**: declaring Inter and Inter-Bold as two families makes fontWeight useless for switching between them
A missing weight is borrowed, not drawn: ask the designer which weights are really used and ship exactly those files
Variable fonts: set the axis explicitly with fontVariations: [FontVariation.weight(600)] rather than relying on fontWeight alone
**google_fonts**: it fetches the weights at run time; bundle the files and set GoogleFonts.config.allowRuntimeFetching = false, so a missing network cannot change your typography
Design tokens are the named decisions of a design system (colours, type styles, spacing, radii) kept as data instead of literals. Primitive tokens name raw values (teal600), semantic tokens name a purpose (surfaceMuted, textDanger), and widgets should only ever see the semantic ones.
Three layers: primitives (the palette), semantic tokens that point at primitives differently per theme, and component tokens (button.radius) only where one widget needs its own
Where they live in Flutter: semantic colours map to ColorScheme roles first, what has no role goes into a ThemeExtension, text styles into TextTheme, component tokens into component themes such as filledButtonTheme
Why semantic: dark mode, a second brand or a contrast fix changes what surfaceMuted points at, not forty widgets
One source of truth: designers keep tokens as Figma variables; export them to JSON (Tokens Studio, Style Dictionary) and generate the Dart classes instead of retyping hex values
Spacing is a token too: a fixed scale (4, 8, 12, 16, 24…) behind named constants, so an EdgeInsets.all(13) stands out in review
ThemeExtension<T> is the built-in way to carry your own tokens inside ThemeData: you subclass it, register instances in ThemeData.extensions, and read them with Theme.of(context).extension<T>(), so they switch and animate with the theme like the built-in colours.
What you implement: final fields, copyWith, and lerp(other, t), which AnimatedTheme calls on every frame of a theme change
Register it in light and dark: an extension present in only one theme has nothing to interpolate with and jumps at the switch
Lerp what is continuous: colours with Color.lerp, gradients with Gradient.lerp; a value that must not be fractional can snap at t < 0.5
Wrap the lookup: extension<T>() returns null when nothing is registered; a static of(context) turns that into one clear error instead of a ! in every widget
Common mistake: tokens in a const file that widgets import directly: no theme switch can reach them
Material 3, the default since Flutter 3.16, colours every component from ColorScheme roles instead of primaryColor, separates layers by surface tone instead of shadow, and changes type, shapes and several widgets. Visible changes are easy to spot; quiet regressions come from old overrides.
The app bar: no longer primaryColor: it is a flat surface that switches to surfaceContainer when content scrolls under it
Components: FilledButton becomes the primary style, corner radii grow, Switch, Slider, Chip, dialogs and the FAB are redrawn, and Typography.material2021 shifts line heights and line breaks
Quiet regressions: hard-coded Colors.white text now sits on a light surface; code that sets ThemeData.primaryColor compiles but components no longer read it, so the override does nothing; old ButtonStyle overrides with M2 radii keep fighting the new defaults
**useMaterial3: false: a temporary migration flag, not a long-term option: Material 2 gets no new work
How to migrate**: screen by screen with goldens, fixing mostly by deleting overrides: many M2 workarounds fought defaults that M3 now gets right
Every Theme also provides a CupertinoTheme (MaterialBasedCupertinoThemeData) that takes just a few values from ThemeData: brightness, primary and contrasting colours, scaffold background and selection handles. Everything else, fonts included, stays at iOS defaults unless set in cupertinoOverrideTheme.
What crosses: brightness, primaryColor from colorScheme.primary, primaryContrastingColor from colorScheme.onPrimary, and scaffoldBackgroundColor (plus selectionHandleColor from textSelectionTheme)
What does not: barBackgroundColor and the whole CupertinoTextThemeData, so your custom font never reaches a CupertinoNavigationBar title; Material component themes mean nothing here
**cupertinoOverrideTheme**: fields you set win, fields left null keep coming from the Material theme; applyThemeToAll: true makes controls like CupertinoSwitch use primaryColor instead of system green
Read the right theme: inside Cupertino widgets use CupertinoTheme.of(context).textTheme, not the Material textTheme
Where it bites: Slider.adaptive, Checkbox.adaptive and CircularProgressIndicator.adaptive build real Cupertino widgets on iOS, so they follow this bridge; Switch.adaptive stays a Material Switch drawn in iOS style and still reads SwitchThemeData
Flutter has no built-in wallpaper palette: read it with the dynamic_color package's DynamicColorBuilder, use those schemes when they are non-null, and fall back to ColorScheme.fromSeed(seedColor: brandSeed) everywhere else.
Where it is available: Android 12 and newer; on iOS, on the web and on older Android the builder hands you null, so the fallback is the common path, not an edge case
Harmonize fixed colours: a status green or a chart series can clash with a stranger's wallpaper; harmonizeWith(scheme.primary) shifts its hue toward the palette while it still reads as green
Refuse it when the colour is the product: a bank card, a logo lockup, a brand-led app: dynamic colour hands your primary to whatever photo the user picked
Test hostile palettes: teams develop on the default wallpaper; a golden test with a deliberately garish ColorScheme shows unreadable states early
Use a ThemeExtension when the value is theme-shaped and should interpolate with the theme; write your own InheritedWidget when it is not: a different lifetime, a different change rate, or values ThemeData has no business carrying.
What a ThemeExtension gives and costs
**It rides ThemeData.lerp: its tokens interpolate across the 200 ms theme change together with the built-in colours
It reaches everywhere Theme.of does**: dialogs and route transitions included, and Theme(data: ...) re-scopes it per subtree
It costs a wide dependency: a widget reading only spacing rebuilds on every colour change, and every field must be immutable and lerpable
Write your own instead when
The change rate differs: a live accent from the playing track, a breakpoint that follows the window size
It carries behaviour: a controller, not paint values
There is no Material above it: under a bare CupertinoApp there is no ThemeData to extend
You want aspect-level rebuilds: InheritedModel lets a widget depend on spacing and ignore colour
Either way, give it maybeOf and an of that fails with a clear message, and pick exactly one home per token.
MaterialApp wraps the app in an AnimatedTheme that lerps ThemeData over 200 ms (kThemeAnimationDuration), so anything that jumps is either outside that widget or a value ThemeData.lerp does not interpolate.
Why values jump
**Some fields snap at t == 0.5**: platform, pageTransitionsTheme, splashFactory, materialTapTargetSize; they switch in one frame by design
**brightness snaps too: a light or dark logo chosen from it swaps instantly instead of fading
An extension registered in only one theme**: one present only in the new theme appears at full value at t = 0; register every ThemeExtension in both
**A lerp that returns this or other: the usual cause of a jumping gradient, and it is self-inflicted
Where the white frame comes from
A rebuild above MaterialApp**: a changed key, or a FutureBuilder on the stored theme mode painting nothing while SharedPreferences loads; read the saved mode before runApp
Things that paint themselves: images and platform views never lerp; cross-fade them with an AnimatedSwitcher
To debug, slow the switch with themeAnimationDuration and screen-record it.
The package exports semantic tokens, a ThemeData factory and brand components (never raw constants), and that surface is versioned like any other public API.
A theme factory per brightness (and per brand): AcmeTheme.light() and .dark(); each app hands the result to MaterialApp instead of building a ColorScheme itself
Tokens behind a typed accessor: context.acme.spacing.md, so no app writes EdgeInsets.all(16); everything under lib/src stays private, and the implementation_imports lint flags apps that reach into it
Semver on names, not pixels: renaming surfaceMuted is a major version, changing the hex behind it a minor one with a changelog entry; deprecate before you delete
How apps depend on it: across repositories, a pinned version from a private pub server or a tagged git: dependency, so each app upgrades on its own schedule; in a monorepo a path: dependency is fine only if CI builds and golden-tests every app on each design-system change
Goldens live in the package: one per component per theme, so a token edit arrives as a reviewable image diff
Make it a CI failure instead of a review comment: a custom lint that flags raw literals outside the design-system package, a committed baseline so the existing count can only fall, and per-theme golden and contrast tests for what a lint cannot see.
The rules: one for Color(...) and Colors.* outside the design-system package, one for EdgeInsets and SizedBox numbers off the spacing scale; written with custom_lint, or as an analyzer plugin on Dart 3.10 and newer
CI is what fails: a squiggle in the IDE fails nothing; run the lint step in CI with the rule at error severity (custom_lint needs its own dart run custom_lint step)
Ratchet a baseline: commit today's violation count per folder and fail only when it rises, so the rule protects new code from day one without a 400-file PR
Goldens per theme: one per component in light and dark; a real token in the wrong role (error on a merely selected chip) only shows up as an image diff
Contrast as a unit test: check every on* pair of each ColorScheme against 4.5:1, so a token change cannot ship unreadable text
Escape hatch with a reason: allow // ignore: only with a comment, and count ignores in the same baseline
Strangle it feature by feature behind a compatibility layer: define the tokens first, point every legacy constant at one, then delete the constants a screen at a time while CI ratchets the remaining count down.
Inventory first: grep the Color(0x...) literals; you usually find far fewer distinct values than literals: the same grey typed nine ways
Cluster them into tokens with the designer: the close-enough calls are made once, here, not per file by whoever has the ticket
Prove the theme on one quiet screen: goldens included; that is where the forgotten tokens surface
Land a compat file: @Deprecated('Use context.acme.surfaceMuted') const kCardGrey = ...; nothing looks different that day, and the analyzer lists every call site
Migrate by feature: capture the golden before the change, so a colour that visibly moves is a decision someone signs off
Never open a 400-file PR: it cannot be reviewed, and one wrong grey hides among the other 399
Give the compat file a removal version: or the temporary bridge quietly becomes the design system