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.
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.
Colours
Never let a component name a colour. It names a role.
@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.
@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
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.
Components consume tokens, never values
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.
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.
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.