Design Systems and Theming in Flutter: Tokens, Dark Mode and Sane Components
A practical guide to Flutter theming and design systems: Material 3 ColorScheme.fromSeed, ThemeExtension tokens, text themes, dark mode and component wrappers.

Search a Flutter codebase for Color(0xFF and count the hits. If the number is bigger than the number of brand colors in your Figma file, you don’t have a design system. You have a collection of opinions, one per screen.
I’ve cleaned that up in more apps than I’d like. The fix is rarely a big library. It’s a small set of decisions made once, put in one place, and enforced by habit and code review. This is how I set that up.
Start with ColorScheme, not colors
Material 3 is the default in current Flutter, and its color system is the part most teams underuse. Instead of picking a dozen hex values, you give Flutter a seed color and it generates a full, contrast-aware palette: primary, onPrimary, primaryContainer, surface, surfaceContainerHigh, outline, error and the rest.
const brandSeed = Color(0xFF1B6EF3);
ThemeData buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: brandSeed,
brightness: brightness,
);
return ThemeData(
colorScheme: colorScheme,
textTheme: _textTheme(colorScheme),
extensions: [
AppSpacing.regular,
brightness == Brightness.dark ? BrandColors.dark : BrandColors.light,
],
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
}
If your designer has exact brand values that must not drift, you can override individual roles with copyWith on the generated scheme. I do that for primary and maybe secondary, and let the generator handle the rest. Hand-picking every role is how you end up with a surface and onSurface pair that fails contrast checks in dark mode.
The habit that matters: widgets read roles, not colors. colorScheme.primaryContainer, not Colors.blue.shade100. Roles survive a rebrand and a dark mode switch. Hex values survive neither.
ThemeExtension for everything Material doesn’t model
ColorScheme covers a lot, but every real product has tokens Material doesn’t know about. A “success” green. A brand gradient. A spacing scale. Corner radii. That’s what ThemeExtension is for, and it’s one of the most useful and least used APIs in Flutter theming.
@immutable
class AppSpacing extends ThemeExtension<AppSpacing> {
const AppSpacing({
required this.xs,
required this.sm,
required this.md,
required this.lg,
});
final double xs;
final double sm;
final double md;
final double lg;
static const regular = AppSpacing(xs: 4, sm: 8, md: 16, lg: 24);
@override
AppSpacing copyWith({double? xs, double? sm, double? md, double? lg}) {
return AppSpacing(
xs: xs ?? this.xs,
sm: sm ?? this.sm,
md: md ?? this.md,
lg: lg ?? this.lg,
);
}
@override
AppSpacing lerp(AppSpacing? other, double t) {
if (other == null) return this;
return AppSpacing(
xs: lerpDouble(xs, other.xs, t)!,
sm: lerpDouble(sm, other.sm, t)!,
md: lerpDouble(md, other.md, t)!,
lg: lerpDouble(lg, other.lg, t)!,
);
}
}
lerpDouble comes from dart:ui. A BrandColors extension looks the same, using Color.lerp instead. The lerp method is why this beats a static constants file: when the theme changes (light to dark, say), AnimatedTheme interpolates your custom tokens along with Material’s.
Reading them is one line, which I wrap in a BuildContext extension so nobody types the generic every time:
extension ThemeX on BuildContext {
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get text => Theme.of(this).textTheme;
AppSpacing get spacing => Theme.of(this).extension<AppSpacing>()!;
BrandColors get brand => Theme.of(this).extension<BrandColors>()!;
}
Now Padding(padding: EdgeInsets.all(context.spacing.md)) reads like the design spec, and a reviewer can spot EdgeInsets.all(13) from across the room.
A quick opinion on spacing: some teams think putting spacing in the theme is overkill and a const class is enough. That’s fine if spacing never varies. I put it in the theme because it costs nothing and leaves room for a compact density on tablets later, which pairs nicely with responsive layouts for phone and tablet.
Text themes: pick roles and stick to them
Material 3 gives you fifteen text roles, from displayLarge down to labelSmall. Designers rarely use all fifteen, and that’s fine. What I do:
- Agree with the designer on which roles map to which Figma text styles. Usually six or seven.
- Set the font family and any weight adjustments once in
_textTheme. - Apply colors from the
ColorScheme, so text always matches the current brightness.
TextTheme _textTheme(ColorScheme colors) {
final base = Typography.material2021().black;
return base
.apply(
fontFamily: 'Inter',
bodyColor: colors.onSurface,
displayColor: colors.onSurface,
)
.copyWith(
titleLarge: base.titleLarge?.copyWith(fontWeight: FontWeight.w600),
);
}
Then screens use context.text.titleLarge, never TextStyle(fontSize: 22). If someone needs a tweak, copyWith on a theme style is acceptable. A brand new TextStyle from scratch should start a conversation.
Leave text scaling alone. Users who set a larger font size need it, and fixed-height containers that clip scaled text are an accessibility bug, not a design choice. More on that in accessibility in Flutter apps.
Dark mode without the drama
If you’ve done the steps above, dark mode is mostly free:
MaterialApp(
theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark),
themeMode: ThemeMode.system,
)
The things that still break:
- Hardcoded colors. Every
Colors.whitebackground becomes a flashlight at night. This is why the “roles, not colors” rule matters. - Images and illustrations. A logo with a dark outline disappears on a dark surface. Ask the designer for variants, or put the asset on a surface that doesn’t change.
- Elevation assumptions. Material 3 shows elevation with surface tint and surface container tones rather than shadows alone. If your cards rely on a shadow to separate from the background, check them in dark mode.
- Status bar icons.
AppBarhandles this for you. Screens without anAppBarneed anAnnotatedRegion<SystemUiOverlayStyle>.
If users can override the system setting, store their choice and feed it into themeMode. That’s a few lines of state, not a redesign.
Component wrappers vs raw widgets
This is where teams argue. One camp wraps everything: AppButton, AppText, AppCard, AppScaffold. The other uses Material widgets directly and styles them through the theme.
I’ve landed in the middle, and my rule is simple: theme first, wrap when the theme can’t express it.
FilledButton, OutlinedButton, Card, InputDecoration and friends all have component themes (filledButtonTheme, cardTheme, and so on). If the design difference is just color, shape, padding or text style, put it in the component theme. Every raw FilledButton in the app then looks right automatically, including the ones in packages you don’t control.
I write a wrapper when the component has behavior or structure Material doesn’t have:
- A primary button that shows a spinner and ignores taps while
isLoadingis true. - A text field with the app’s label, helper and error layout baked in (the kind of thing I cover in better forms in Flutter).
- An empty state with an illustration, title, message and action.
What I avoid is the AppText wrapper that takes a fontSize parameter. It adds a layer without adding a rule. If the wrapper accepts arbitrary styling, it’s just Text with extra steps.
Keeping designers and code in sync
The theme file is only useful if it matches what the designer is drawing. A few habits help more than any tool:
- Name tokens the same on both sides. If Figma says
spacing/mdandcolor/success, the code saysspacing.mdandbrand.success. Translation layers are where drift starts. - Build a gallery screen. A debug-only route that renders every color role, text style and wrapped component in light and dark. Designers can check it on a real device, and it makes regressions obvious. Widget previews in recent Flutter versions help here too.
- Lint the escape hatches. A simple CI grep for
Color(0xandTextStyle(outside the theme folder catches most drift. It doesn’t need to be fancy. - Change tokens, not screens. When the designer says “make the cards a bit rounder,” the fix should be one line in the theme. If it’s forty lines across twenty screens, that’s your signal to pull another value into the system.
None of this needs a design-tokens pipeline that generates Dart from JSON. That’s worth it for large teams with multiple platforms sharing tokens. For most apps, a well-organized theme/ folder and a designer who knows where it is gets you most of the value.
Takeaways
- Use
ColorScheme.fromSeedand override only the roles your brand pins down. - Widgets read color roles and text roles, never raw hex values or ad hoc
TextStyles. - Put custom tokens like spacing, radii and brand colors in a
ThemeExtensionso they animate and switch with the theme. - Dark mode is cheap if you followed the rules above, and painful if you didn’t.
- Style through component themes first. Wrap a widget only when it needs behavior the theme can’t express.
- Keep names identical between Figma and code, and make drift visible with a gallery screen and a CI check.
A good theme is boring to work with, and that’s exactly what you want from it.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).