Building a Design System for Flutter
Tokens, themes, and components that survive a rebrand, colours, typography, spacing, and the primitives every screen is built from.
Every Flutter app accumulates a design system whether you plan one or not. The
question is only whether it is written down, or scattered across four hundred
EdgeInsets.all(16) calls and a Color(0xFF3B82F6) that appears in nineteen
files with two subtly different values.
The unplanned version works fine for months. It fails at exactly two moments: when the brand changes, and when dark mode is requested. Both turn into a find-and-replace across the entire codebase, and both leave stragglers that someone notices in production.
A design system is not a component library you build up front. It is a set of decisions made once, expressed as tokens, and reached through the theme rather than hardcoded. This post covers the token layers, how to attach them to Flutter's theming, and the components worth building first.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: name your colours, sizes and text styles once, reach them only through the theme, and a rebrand or a dark mode becomes an edit to one file instead of a search across the app.
Terms
| Term | Meaning |
|---|---|
| Design system | The written-down set of visual decisions an app makes, plus the code that enforces them. |
| Token | One named design value, like space4 or brand. The unit a design system is built from. |
| Primitive token | A raw value with a descriptive name, like blue500. Says what it is. |
| Semantic token | A role pointing at a primitive, like danger. Says what it is for. |
| Theme | The bundle of styling values Flutter carries down the widget tree, read with Theme.of(context). |
ThemeExtension | Your own block of values riding along inside Flutter's theme, for roles Material does not define. |
ColorScheme | Material's own set of colour roles, used by its built-in widgets. |
| Brightness | Whether the theme is the light or the dark one. |
lerp | Short for linear interpolation. Blending two values so a theme change animates instead of snapping. |
| Scale | A fixed, small set of allowed values, like six spacing steps. The point is what it excludes. |
| Radius | How rounded a corner is. |
| Line height | The vertical space a line of text occupies, set with height in a TextStyle. |
| Variant | A named version of a component, like a primary or ghost button, rather than a free-form styling parameter. |
| Extension method | Dart syntax for adding a method or getter to a type you do not own, used here for context.colors. |
| Golden test | A test that compares a rendered widget against a stored reference image. |
| Breakpoint | A screen width at which the layout changes. |
| Lint rule | An automated check that fails the build on a pattern you have banned. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| JSON | JavaScript Object Notation | A common text format for data, used here to hold exported design tokens |
| FAQ | Frequently Asked Questions | The question section near the end |
Tokens before components
The instinct is to start with a button. Start with the values instead, a button built on hardcoded colours is a hardcoded button with extra steps.
Two layers are enough for most apps.
Primitive tokens are raw values with descriptive names: blue500,
space4, radiusMd. They describe what they are.
Semantic tokens are roles that point at primitives: surface,
textPrimary, danger, spacingCard. They describe what they are for.
Components only ever reference semantic tokens. That indirection is the whole mechanism behind theming, dark mode is the same semantic names pointing at different primitives, and a rebrand is editing the primitive layer once.
Going deeper: colours
Never let a component name a colour. It names a role. A card does not want white, it wants the surface colour, whatever that happens to be today.
The class below is how you attach your own roles to Flutter's theme. It is long but shapeless, six fields, one set of values for light, one for dark, and two methods Flutter requires so themes can be copied and animated between.
@immutable
class AppColors extends ThemeExtension<AppColors> {
const AppColors({
required this.surface,
required this.surfaceMuted,
required this.textPrimary,
required this.textMuted,
required this.brand,
required this.danger,
});
final Color surface;
final Color surfaceMuted;
final Color textPrimary;
final Color textMuted;
final Color brand;
final Color danger;
static const light = AppColors(
surface: Color(0xFFFFFFFF),
surfaceMuted: Color(0xFFF4F4F5),
textPrimary: Color(0xFF18181B),
textMuted: Color(0xFF71717A),
brand: Color(0xFF10B981),
danger: Color(0xFFDC2626),
);
static const dark = AppColors(
surface: Color(0xFF09090B),
surfaceMuted: Color(0xFF18181B),
textPrimary: Color(0xFFFAFAFA),
textMuted: Color(0xFFA1A1AA),
brand: Color(0xFF34D399),
danger: Color(0xFFF87171),
);
@override
AppColors copyWith({Color? surface, Color? brand /* … */}) => AppColors(
surface: surface ?? this.surface,
surfaceMuted: surfaceMuted,
textPrimary: textPrimary,
textMuted: textMuted,
brand: brand ?? this.brand,
danger: danger,
);
@override
AppColors lerp(AppColors? other, double t) {
if (other == null) return this;
return AppColors(
surface: Color.lerp(surface, other.surface, t)!,
surfaceMuted: Color.lerp(surfaceMuted, other.surfaceMuted, t)!,
textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!,
textMuted: Color.lerp(textMuted, other.textMuted, t)!,
brand: Color.lerp(brand, other.brand, t)!,
danger: Color.lerp(danger, other.danger, t)!,
);
}
}ThemeExtension is the piece people miss. Flutter's built-in ColorScheme
covers Material's roles, not yours, it has no opinion about surfaceMuted or
your brand accent. An extension gives you named roles that ride along with the
theme and animate correctly when it changes, which is what lerp is for.
Note that dark is not light inverted. brand is lighter in dark mode, because
a colour with enough contrast on white is usually too dim on near-black. Getting
that wrong is what makes a dark theme look muddy.
Typography
Define a scale, not a font size. Every text style in the app should come from a fixed set, because "16 or 17 here?" is a decision nobody should make twice.
Same idea as the colours, applied to text. Five named styles, each one a font, a size and a line height, and no screen is allowed to invent a sixth.
@immutable
class AppTypography extends ThemeExtension<AppTypography> {
const AppTypography({
required this.displayLarge,
required this.heading,
required this.body,
required this.bodySmall,
required this.mono,
});
final TextStyle displayLarge;
final TextStyle heading;
final TextStyle body;
final TextStyle bodySmall;
final TextStyle mono;
static final base = AppTypography(
displayLarge: GoogleFonts.oxanium(fontSize: 32, height: 1.2, fontWeight: FontWeight.w700),
heading: GoogleFonts.oxanium(fontSize: 20, height: 1.3, fontWeight: FontWeight.w600),
body: GoogleFonts.inter(fontSize: 15, height: 1.55),
bodySmall: GoogleFonts.inter(fontSize: 13, height: 1.5),
mono: GoogleFonts.jetBrainsMono(fontSize: 13, height: 1.5),
);
// copyWith / lerp omitted
}Two details worth being deliberate about. Set height (line height) explicitly, Flutter's default leading is tight and inconsistent across fonts, and it is the
single biggest reason an app looks cramped next to its Figma mockup. And keep
colour out of these styles: a TextStyle that hardcodes black cannot be
reused in dark mode.
Spacing and radii
The smallest layer and the one that pays off fastest.
abstract final class Space {
static const xs = 4.0;
static const sm = 8.0;
static const md = 12.0;
static const lg = 16.0;
static const xl = 24.0;
static const xxl = 32.0;
}
abstract final class Radii {
static const sm = 6.0;
static const md = 10.0;
static const lg = 16.0;
static const pill = 999.0;
}A fixed scale removes an entire category of review comment. Nobody debates 14 versus 16 again, spacing becomes visually consistent by construction, and changing the app's rhythm globally is a six-line edit.
Keep the scale small. Eight spacing values is a system. Twenty is a palette of excuses.
Wiring tokens into the theme
Tokens in a file are constants. They become a design system when components can only reach them through the theme.
Register the extensions
Flutter needs to be told about your colour and typography classes. You hand them
to ThemeData in its extensions list, build one theme for light and one for
dark, and give both to MaterialApp.
ThemeData buildTheme(Brightness brightness) {
final colors = brightness == Brightness.dark ? AppColors.dark : AppColors.light;
return ThemeData(
brightness: brightness,
scaffoldBackgroundColor: colors.surface,
extensions: [colors, AppTypography.base],
);
}
// main.dart
MaterialApp(
theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark),
themeMode: ThemeMode.system,
);Give yourself a terse accessor
Theme.of(context).extension<AppColors>()! at every call site is unusable. One
extension method fixes it:
extension ThemeX on BuildContext {
AppColors get colors => Theme.of(this).extension<AppColors>()!;
AppTypography get type => Theme.of(this).extension<AppTypography>()!;
}
// usage
Text('Total', style: context.type.heading.copyWith(color: context.colors.textPrimary));Now dark mode is free everywhere, and a component that hardcodes a colour stands out in review because it looks different from every neighbour.
Going deeper: components consume tokens, never values
Here is the payoff. This button asks for nothing but a label and a variant. Every colour, radius and spacing it uses comes from the theme, so it is already correct in dark mode and already correct after a rebrand.
class AppButton extends StatelessWidget {
const AppButton({super.key, required this.label, this.onPressed, this.variant = ButtonVariant.primary});
final String label;
final VoidCallback? onPressed;
final ButtonVariant variant;
@override
Widget build(BuildContext context) {
final c = context.colors;
final isPrimary = variant == ButtonVariant.primary;
return Material(
color: isPrimary ? c.brand : Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.pill),
side: isPrimary ? BorderSide.none : BorderSide(color: c.textMuted),
),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(Radii.pill),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Space.xl, vertical: Space.md),
child: Text(
label,
style: context.type.body.copyWith(
color: isPrimary ? c.surface : c.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
}Note what is not here: no size parameter taking arbitrary doubles, no colour parameter. A variant enum with three values is a design system. A component with twelve styling parameters is a styling function that has given up.
The components worth building first
Build these four before anything else. Together they cover most of a typical app, and each one enforces the tokens on whoever uses it.
Buttons, primary, secondary, ghost, plus disabled and loading states. The loading state matters more than it sounds: without it, every screen invents its own spinner placement.
Inputs, a text field wrapping label, hint, error, and focus states in one
component. Flutter's InputDecoration is powerful and verbose, and left to
themselves, ten screens will configure it ten ways. Wrap it once.
Cards, the surface primitive. Padding, radius, border, and elevation decided in one place, so every list item and panel in the app agrees.
Themes, light and dark as first-class, tested outputs rather than an afterthought. Build the dark variant on day one. Retrofitting it means auditing every widget for a hardcoded colour.
After those, add components only when the same pattern has appeared three times. Building a component library speculatively produces widgets nobody uses with parameters nobody needs.
Going deeper: keep the system visible
A design system nobody can see gets bypassed. A /design route in debug builds, every token swatch, every component in every variant and state, in both themes, takes an afternoon and pays for itself the first time someone checks whether a
component exists before writing a new one.
widgetbook or storybook_flutter do this properly if you want golden tests
and knobs. A plain scrollable page is enough to start.
Key takeaways
- Tokens first, components second. A button built on hardcoded values is a hardcoded button.
- Two token layers: primitives (
blue500) and semantics (brand,surface). Components touch only the semantic layer. - Use
ThemeExtensionfor roles Material'sColorSchemedoes not cover, and implementlerpso theme changes animate. - Dark is not inverted light. Brand colours usually need to be lighter, not darker, on a dark surface.
- Set line height explicitly in text styles, and keep colour out of them.
- A small spacing scale ends a whole class of review comment and makes the app's rhythm editable in one place.
- Add a
context.colors/context.typeaccessor. Verbose access is the reason people hardcode values. - Build buttons, inputs, cards, and both themes first, then wait for the third repetition before adding anything else.
FAQ
Should I use Material 3's ColorScheme or my own extension?
Both. ColorScheme keeps Material widgets, dialogs, snackbars, pickers, looking right without effort. A ThemeExtension carries the roles your design
has and Material does not. Fighting ColorScheme into a shape it was not built
for is wasted effort.
Where do design tokens come from?
Ideally exported from Figma variables rather than transcribed by hand. Style Dictionary can generate the Dart file from a token JSON, which removes the copy-paste step where a hex digit gets dropped. For a small app, a hand-written file is fine, just make it the only place those values exist.
How do I stop people hardcoding colours?
A custom lint rule is the reliable answer. A review habit works on small teams.
The most effective measure is making the right thing easier: context.colors.brand
is shorter to type than Color(0xFF10B981), so people reach for it.
Should the design system be a separate package?
Only if more than one app consumes it. Inside a single app, a lib/design/
folder is enough, and a package adds a versioning ceremony with no benefit. Two
apps sharing it changes that calculation completely.
How do I handle responsive sizing?
Scale spacing at a few breakpoints rather than making every value a function of
screen width. Tokens like Space.lg staying constant is a feature, text
already scales via MediaQuery.textScaler, and multiplying spacing on top of
that produces layouts that break at unusual settings.
What about animation?
Tokenise durations and curves too: Motion.fast, Motion.standard,
Motion.emphasized. It is the same argument as spacing, and it prevents an app
where every transition has a slightly different feel.
Conclusion
A design system is mostly bookkeeping, and its value is not that it makes building the first screen faster, it does not. It makes the fiftieth screen consistent with the first, and it makes a rebrand or a dark mode a change to a handful of files instead of an archaeology project.
Start with the smallest version that is real: colours, typography, spacing, wired through the theme, with four components consuming them. That is a weekend's work, and everything after it is cheaper than it would otherwise have been.
References
Official documentation for the topics covered here.
- Flutter API: ThemeExtension - the class that carries your own token block inside Flutter's theme, including
lerp - Flutter API: ThemeData - everything the built-in theme already defines before you extend it
- Flutter API: ColorScheme - Material's colour roles, and
fromSeedfor generating a consistent set - Flutter API: TextStyle - the type your typography tokens resolve to, including
heightfor line height - Flutter: Use themes to share colors and font styles - wiring a theme in and reading it back with
Theme.of(context) - Flutter: Adaptive and responsive design - breakpoints and layout that changes with available width
- Flutter: Testing overview - the testing page that covers golden tests for locking component appearance
Read more
A design system is the presentation layer's foundation. The layers underneath matter just as much. See How to Structure a Scalable Flutter Application for where components sit in a layered codebase, and How Flutter Rendering Actually Works for what your reusable widgets cost per frame.