Most Flutter codebases we inherit have the same fingerprint: a colors.dart with forty constants, a handful of TextStyles copy-pasted into widgets, and a Theme.of(context) call here and there that disagrees with all of it. Nothing is broken, exactly. But a brand refresh takes three weeks, dark mode is half-wrong, and no two engineers build the same button.
A design system fixes that, and in Flutter it is not a package you install — it is three layers you own: tokens, theme, and components. This tutorial builds all three on Flutter 3.4x with Material 3, including the Material 3 Expressive motion and shape updates, and locks the result down with golden tests so it cannot silently drift.
The three layers, and why the boundary matters
design tokens (JSON from Figma) <- values, no Flutter types
| codegen
ThemeData + ThemeExtensions <- semantic names: surfaceRaised, textMuted
| Theme.of(context)
components (AppButton, AppCard) <- the only widgets product code uses
|
screens
The rule that makes it work: product code never reads a raw token. A screen asks for context.colors.surfaceRaised, not Palette.slate800. Raw values live at the bottom; meaning lives in the middle. That single boundary is what lets you re-theme a white-label app, add dark mode, or raise contrast for accessibility without touching a screen.
Layer 1: tokens as data, not Dart
Export tokens from Figma (Variables, or Tokens Studio) as JSON and commit them. Keep them dumb:
{
"color": {
"brand": { "500": "#2F6BFF", "600": "#1F4FD6" },
"neutral": { "0": "#FFFFFF", "900": "#101317" }
},
"space": { "xs": 4, "sm": 8, "md": 16, "lg": 24 },
"radius": { "sm": 8, "md": 12, "pill": 999 },
"duration": { "fast": 150, "normal": 250, "emphasized": 450 }
}
Generate Dart from it rather than hand-maintaining a mirror. A ~60-line script in tool/gen_tokens.dart run from a Melos script or a build_runner builder is enough:
// tool/gen_tokens.dart -> lib/design/generated/tokens.g.dart
void main() {
final json = jsonDecode(File('design/tokens.json').readAsStringSync());
final out = StringBuffer('// GENERATED — do not edit.\nimport \'dart:ui\';\n\nclass T {\n');
_flatten(json['color']).forEach((k, v) =>
out.writeln(' static const Color color${_pascal(k)} = Color(0xFF${v.substring(1)});'));
_flatten(json['space']).forEach((k, v) => out.writeln(' static const double space${_pascal(k)} = $v;'));
out.writeln('}');
File('lib/design/generated/tokens.g.dart').writeAsStringSync(out.toString());
}
Then add a CI step that regenerates and fails on a dirty diff:
- run: dart run tool/gen_tokens.dart && git diff --exit-code lib/design/generated
Designers change a hex code, the PR shows exactly which generated constants moved, and nobody argues about whether the app matches the file.
Layer 2: semantic theme with ThemeExtension
ColorScheme covers Material's roles, and Material 3 gives you a lot for free from ColorScheme.fromSeed. But every real product needs roles Material has no name for: a raised surface, a muted caption, a "success" state, brand gradients. That is what ThemeExtension is for — and unlike a global singleton, it survives nested themes, white-label flavors, and widget tests.
// lib/design/app_colors.dart
@immutable
class AppColors extends ThemeExtension<AppColors> {
const AppColors({
required this.surfaceRaised,
required this.textMuted,
required this.success,
required this.warning,
});
final Color surfaceRaised;
final Color textMuted;
final Color success;
final Color warning;
static const light = AppColors(
surfaceRaised: T.colorNeutral0,
textMuted: Color(0xFF5C6773),
success: Color(0xFF1B8A5A),
warning: Color(0xFFB8860B),
);
static const dark = AppColors(
surfaceRaised: Color(0xFF1A1F26),
textMuted: Color(0xFF9AA5B1),
success: Color(0xFF4ADE80),
warning: Color(0xFFFBBF24),
);
@override
AppColors copyWith({Color? surfaceRaised, Color? textMuted, Color? success, Color? warning}) =>
AppColors(
surfaceRaised: surfaceRaised ?? this.surfaceRaised,
textMuted: textMuted ?? this.textMuted,
success: success ?? this.success,
warning: warning ?? this.warning,
);
@override
AppColors lerp(AppColors? other, double t) {
if (other == null) return this;
return AppColors(
surfaceRaised: Color.lerp(surfaceRaised, other.surfaceRaised, t)!,
textMuted: Color.lerp(textMuted, other.textMuted, t)!,
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
);
}
}
Implementing lerp properly is not busywork: it is what makes a light/dark switch animate instead of snapping, and Flutter calls it on every theme transition.
Do the same for spacing, radii and durations (AppSpacing, AppMotion), then wire them in:
ThemeData buildTheme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: T.colorBrand500,
brightness: brightness,
);
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
textTheme: _textTheme(scheme),
extensions: [
brightness == Brightness.light ? AppColors.light : AppColors.dark,
const AppSpacing(),
const AppMotion(),
],
);
}
One small extension on BuildContext removes all the ceremony at call sites:
extension ThemeX on BuildContext {
ColorScheme get scheme => Theme.of(this).colorScheme;
AppColors get colors => Theme.of(this).extension<AppColors>()!;
AppSpacing get space => Theme.of(this).extension<AppSpacing>()!;
AppMotion get motion => Theme.of(this).extension<AppMotion>()!;
}
// usage
Container(
padding: EdgeInsets.all(context.space.md),
color: context.colors.surfaceRaised,
)
The ! is deliberate. A missing extension should throw loudly in development, not silently fall back to a default that only shows up in a client demo.
Material 3 Expressive: shape and motion as tokens
Material 3 Expressive moved shape and motion from decoration to signal — larger corner radii, shape changes on press, and a motion system with distinct emphasized and standard easing. Flutter exposes these through Easing and Durations constants, so you do not need custom curve maths:
@immutable
class AppMotion extends ThemeExtension<AppMotion> {
const AppMotion();
Duration get fast => Durations.short3; // ~150ms — state changes
Duration get normal => Durations.medium2; // ~300ms — enter/exit
Duration get emphasized => Durations.long2; // ~500ms — hero moments
Curve get standard => Easing.standard;
Curve get enter => Easing.emphasizedDecelerate;
Curve get exit => Easing.emphasizedAccelerate;
// ... copyWith / lerp omitted
}
Two rules we enforce in review:
- Durations come from the theme, never from a literal.
Duration(milliseconds: 220)scattered across 40 widgets is unauditable, and it ignores the platform's reduce-motion setting. - Respect
MediaQuery.disableAnimationsOf(context). Accessibility settings and screenshot tests both turn animations off; honour it in one place:
Duration motionFor(BuildContext context, Duration d) =>
MediaQuery.disableAnimationsOf(context) ? Duration.zero : d;
For the expressive shape-morph on press, AnimatedContainer plus a token radius gets you most of the way:
AnimatedContainer(
duration: motionFor(context, context.motion.fast),
curve: context.motion.standard,
decoration: BoxDecoration(
color: context.scheme.primaryContainer,
borderRadius: BorderRadius.circular(_pressed ? T.radiusSm : T.radiusMd),
),
child: child,
)
Layer 3: components that are hard to misuse
A component library earns its keep by removing choices. AppButton takes a variant, not a colour:
enum AppButtonVariant { primary, secondary, danger, ghost }
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.label,
required this.onPressed,
this.variant = AppButtonVariant.primary,
this.loading = false,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final AppButtonVariant variant;
final bool loading;
final IconData? icon;
@override
Widget build(BuildContext context) {
final style = switch (variant) {
AppButtonVariant.primary => FilledButton.styleFrom(
backgroundColor: context.scheme.primary,
foregroundColor: context.scheme.onPrimary),
AppButtonVariant.secondary => FilledButton.styleFrom(
backgroundColor: context.scheme.secondaryContainer,
foregroundColor: context.scheme.onSecondaryContainer),
AppButtonVariant.danger => FilledButton.styleFrom(
backgroundColor: context.scheme.error,
foregroundColor: context.scheme.onError),
AppButtonVariant.ghost => FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: context.scheme.primary),
};
return Semantics(
button: true,
enabled: onPressed != null && !loading,
label: label,
child: FilledButton(
style: style,
onPressed: loading ? null : onPressed,
child: AnimatedSwitcher(
duration: motionFor(context, context.motion.fast),
child: loading
? const SizedBox(
key: ValueKey('spinner'),
width: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: Row(
key: const ValueKey('label'),
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[Icon(icon, size: 18), SizedBox(width: context.space.xs)],
Text(label),
],
),
),
),
);
}
}
Note what is baked in and therefore cannot be forgotten: the semantics node, a minimum tap target via the button's own theme, a loading state that disables the callback, and motion from the theme. Every one of those is something a screen author would otherwise get wrong at some point, in some screen, under deadline.
For white-label work, put the component library in its own package (packages/design_system) inside a Melos monorepo. Brand flavors then differ by one file — the token JSON — and the apps share everything else.
Stop raw values at the gate
A design system decays one Color(0xFF...) at a time. Make it a lint:
# analysis_options.yaml
custom_lint:
rules:
- avoid_hard_coded_colors
If you do not want a custom_lint plugin, a CI grep covering lib/features/ (and excluding lib/design/) catches 90% of it:
! grep -rnE "Color\(0x|EdgeInsets\.all\([0-9]" lib/features/ \
|| { echo "Use design tokens, not raw values"; exit 1; }
Pair it with flutter analyze --fatal-infos and the rule stops being a code-review opinion and becomes a build result.
Golden tests: the contract that keeps it honest
A design system without goldens is a style guide with extra steps. Render every component, in every variant, in both brightnesses:
// test/design/app_button_golden_test.dart
void main() {
for (final brightness in Brightness.values) {
testWidgets('AppButton variants — ${brightness.name}', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildTheme(brightness),
home: Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in AppButtonVariant.values)
Padding(
padding: const EdgeInsets.all(8),
child: AppButton(label: v.name, variant: v, onPressed: () {}),
),
const AppButton(label: 'Saving', loading: true, onPressed: null),
],
),
),
),
));
await expectLater(
find.byType(Column),
matchesGoldenFile('goldens/app_button_${brightness.name}.png'),
);
});
}
}
Three things make goldens survivable in CI rather than a constant source of false failures:
- Generate them on one platform only. Font rasterisation differs between macOS and Linux. Run
flutter test --update-goldensin the same Linux container CI uses, or tag them@Tags(['golden'])and only run that tag there. - Load the real fonts in
flutter_test_config.dart; the default test font is a blank-box placeholder that hides spacing bugs. - Test scale factors too. Wrap in
MediaQuery(data: MediaQueryData(textScaler: TextScaler.linear(1.5)))and you will find everyRowthat overflows at accessibility text sizes — before a user does.
Add a text-scale and a semantics check next to the pixel check, and the suite covers meaning as well as appearance:
testWidgets('AppButton exposes a button semantics node', (tester) async {
await tester.pumpWidget(_wrap(const AppButton(label: 'Save', onPressed: null)));
expect(tester.getSemantics(find.text('Save')), matchesSemantics(label: 'Save', isButton: true));
});
How we roll this into an existing app
Retrofitting beats rewriting. The order we use on client engagements:
- Inventory. Grep for every
Color(0x,TextStyle(, andEdgeInsetsliteral. The counts, not the opinions, make the business case. - Tokens + theme first, with the old constants aliased to the new generated ones. Nothing visually changes; the PR is boring and safe.
- One component at a time, starting with buttons and text fields — highest occurrence count, lowest risk. Replace call sites feature by feature.
- Goldens on each component as it lands, so the migration itself cannot regress.
- Turn on the lint last, once
lib/features/is clean, so it never fails a build on legacy code.
A mid-size app typically takes two to three weeks of one engineer's time to get through steps 1–4, and the payback shows up the first time a brand refresh or a dark-mode request arrives and costs a day instead of a sprint.
The short version
- Tokens are data, generated into Dart and verified in CI — never hand-mirrored.
ThemeExtensiongives you semantic roles Material does not name, andlerpmakes theme changes animate.- Material 3 Expressive shape and motion belong in tokens too, gated by
disableAnimationsOf. - Components encode the decisions — variants, semantics, tap targets, loading states — so screens cannot get them wrong.
- Golden plus semantics plus text-scale tests are the contract that stops the system from decaying.
Building or untangling a Flutter design system across several apps or brands? Get in touch — our Flutter consultants do this work as a focused engagement, and the deliverable is a package your own team owns.