NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile13 min read

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.

flutterdartdesign-systemsuitheming

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

TermMeaning
Design systemThe written-down set of visual decisions an app makes, plus the code that enforces them.
TokenOne named design value, like space4 or brand. The unit a design system is built from.
Primitive tokenA raw value with a descriptive name, like blue500. Says what it is.
Semantic tokenA role pointing at a primitive, like danger. Says what it is for.
ThemeThe bundle of styling values Flutter carries down the widget tree, read with Theme.of(context).
ThemeExtensionYour own block of values riding along inside Flutter's theme, for roles Material does not define.
ColorSchemeMaterial's own set of colour roles, used by its built-in widgets.
BrightnessWhether the theme is the light or the dark one.
lerpShort for linear interpolation. Blending two values so a theme change animates instead of snapping.
ScaleA fixed, small set of allowed values, like six spacing steps. The point is what it excludes.
RadiusHow rounded a corner is.
Line heightThe vertical space a line of text occupies, set with height in a TextStyle.
VariantA named version of a component, like a primary or ghost button, rather than a free-form styling parameter.
Extension methodDart syntax for adding a method or getter to a type you do not own, used here for context.colors.
Golden testA test that compares a rendered widget against a stored reference image.
BreakpointA screen width at which the layout changes.
Lint ruleAn automated check that fails the build on a pattern you have banned.

Abbreviations

ShortFull formIn plain words
JSONJavaScript Object NotationA common text format for data, used here to hold exported design tokens
FAQFrequently Asked QuestionsThe 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 ThemeExtension for roles Material's ColorScheme does not cover, and implement lerp so 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.type accessor. 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.

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.