Build a scalable Flutter design system with semantic design tokens, ThemeExtension theming, intent based component APIs, and lint rules that stop UI drift as teams grow.
Every Flutter app I've shipped started clean and drifted. Week one, the code is disciplined and the screens agree with each other. By month six, one screen uses Color(0xFF3366FF), another uses Colors.blue, a third hardcodes EdgeInsets.all(16) while its neighbor two commits later uses 18 because someone eyeballed it against a Figma frame at 90% zoom. Multiply that by six years and a growing team, and you get an app that looks like five apps stitched together with a shared logo.
The reflex is to blame discipline. It isn't discipline. Nobody wakes up wanting to hardcode a hex value; they do it because in that moment it was the fastest way to ship the thing on their plate, and the "correct" way was either invisible or three layers of ceremony away. A Flutter design system isn't a Figma file or a folder of pretty widgets — it's a set of constraints that make the inconsistent choice harder to make than the correct one. Get that inversion right and consistency stops being a virtue you have to nag people about. It becomes the default. Here's how I actually build and maintain a scalable design system in production Flutter apps, including the parts I got wrong the first few times.
Before writing a line of code, it helps to name the layers, because most arguments about design systems are really people talking past each other about different layers. A mature Flutter design system has three, and they stack in a strict dependency order:
The rule that makes it survive growth is that dependencies only ever point down the stack. Patterns depend on components, components depend on tokens, tokens depend on nothing. The moment a token starts reaching up into a widget, or a component hardcodes a value instead of reading a token, the system has a leak — and leaks are how you end up back at five apps in a trench coat.
The mistake I see most often — and the one I made myself on my first two products — is jumping straight to reusable widgets. You build a PrimaryButton, feel productive, and move on. But a PrimaryButton that hardcodes its own padding and color is just a nicely-named hardcode. You've moved the magic numbers into a class and called it a system. Six months later the brand refreshes, and you're editing that widget file with the same anxiety you'd have had editing the raw call sites, because you don't actually know what else copied those values.
The foundation is design tokens: named, semantic values for color, spacing, typography, radius, and elevation. Widgets consume tokens. Tokens never consume widgets. That dependency direction is the whole game.
The key word is semantic. Don't name a color blue500 — name it by its role. When the brand color changes, and it always changes, you want to swap one value, not hunt down every blue500 and decide case-by-case whether it was really the brand color or just happened to be blue that day. I learned this the expensive way: an app where "the blue" was used for the primary action, links, the selected tab, and a decorative gradient. Marketing wanted a warmer primary. Three of those four should have moved. One shouldn't. Because they all shared one literal, there was no way to tell them apart without reading every usage. Semantic tokens are how you make future-you's search-and-replace safe.
// Raw palette — private, never referenced by UI directlyclass _Palette { static const brand = Color(0xFF3366FF); static const ink = Color(0xFF0A0A0A); static const cloud = Color(0xFFF4F5F7); static const danger = Color(0xFFE5484D); static const success = Color(0xFF2E9B5B);}// Semantic tokens — this is what widgets consume@immutableclass AppColors extends ThemeExtension<AppColors> { const AppColors({ required this.primary, required this.surface, required this.onSurface, required this.muted, required this.error, required this.success, }); final Color primary; final Color surface; final Color onSurface; final Color muted; final Color error; final Color success; static const light = AppColors( primary: _Palette.brand, surface: Colors.white, onSurface: _Palette.ink, muted: _Palette.cloud, error: _Palette.danger, success: _Palette.success, ); static const dark = AppColors( primary: _Palette.brand, surface: Color(0xFF141414), onSurface: Colors.white, muted: Color(0xFF1F1F1F), error: _Palette.danger, success: _Palette.success, ); @override AppColors copyWith({ Color? primary, Color? surface, Color? onSurface, Color? muted, Color? error, Color? success, }) { return AppColors( primary: primary ?? this.primary, surface: surface ?? this.surface, onSurface: onSurface ?? this.onSurface, muted: muted ?? this.muted, error: error ?? this.error, success: success ?? this.success, ); } @override AppColors lerp(AppColors? other, double t) { if (other == null) return this; return AppColors( primary: Color.lerp(primary, other.primary, t)!, surface: Color.lerp(surface, other.surface, t)!, onSurface: Color.lerp(onSurface, other.onSurface, t)!, muted: Color.lerp(muted, other.muted, t)!, error: Color.lerp(error, other.error, t)!, success: Color.lerp(success, other.success, t)!, ); }}Notice ThemeExtension. This is the single most useful piece of theming infrastructure Flutter gives you, and it's badly underused. Notice also that light and dark are just two instances of the same shape. Dark mode isn't a separate code path — it's a different set of token values. That's the payoff of separating raw palette from semantic role: theming becomes data, not logic.
If you want the system to age well, split tokens into two tiers explicitly. Reference tokens are the raw palette (_Palette.brand) — the ground truth of "what colors exist." Semantic tokens are the roles (primary, surface, error) that map onto them. In the code above, _Palette is the reference tier and AppColors is the semantic tier. This two-tier model is exactly how mature design systems (Material 3's own token spec included) stay flexible: a rebrand touches the reference tier, a role remapping touches the semantic tier, and feature code — which only ever sees the semantic tier — never has to change at all. When someone asks "can we A/B test a new accent color," the answer is a one-line swap instead of a two-week refactor.
Flutter's built-in ThemeData and ColorScheme are fine, but they were designed around Material's vocabulary — primary, secondary, surfaceContainerHighest, and a dozen slots you'll never touch. Real products have their own vocabulary: a "success" state, a "muted" caption color, a "card border" that doesn't map cleanly onto any Material slot. You can try to jam your semantics into Material's names, and I did, and it's a slow-motion mistake. Six months in, secondary means three unrelated things because it was the least-wrong slot each time someone needed a color Material didn't have a word for.
ThemeExtension<T> lets you attach your own semantic tokens to the theme and read them anywhere with full type safety and free dark-mode lerping during theme transitions. Because the framework calls lerp on every extension while animating between themes, a MaterialApp switching from light to dark cross-fades your custom tokens automatically — no manual AnimatedContainer bookkeeping.
final lightTheme = ThemeData( useMaterial3: true, brightness: Brightness.light, extensions: const [AppColors.light, AppSpacing.standard, AppText.standard],);final darkTheme = ThemeData( useMaterial3: true, brightness: Brightness.dark, extensions: const [AppColors.dark, AppSpacing.standard, AppText.standard],);// Anywhere in the tree:final colors = Theme.of(context).extension<AppColors>()!;Container(color: colors.surface);
That Theme.of(context).extension<AppColors>()! is correct but noisy, and noisy access is its own kind of friction — friction pushes people back toward Colors.blue. So I wrap it in a tiny BuildContext extension. The call sites end up reading almost like prose, which matters more than it sounds: readable access is what makes the system feel lighter than the escape hatch.
extension ThemeX on BuildContext { AppColors get colors => Theme.of(this).extension<AppColors>()!; AppSpacing get space => Theme.of(this).extension<AppSpacing>()!; AppText get text => Theme.of(this).extension<AppText>()!;}// Usage: context.colors.primary, context.space.md, context.text.bodyOne caveat worth knowing before you lean on context.colors everywhere: Theme.of(context) registers a dependency, so a widget that reads it rebuilds whenever the theme changes. That's exactly what you want for theme switches, but it means you should read tokens inside build where they're used, not cache them in a State field that never refreshes. In practice this is a non-issue — Flutter's rebuilds are cheap and the dependency is precisely what makes live theme switching and dynamic-color work — but it's the kind of thing that trips people up once, so I'm naming it.
For spacing and typography, apply the exact same discipline. Spacing is a scale, not a grab-bag of arbitrary numbers — xs, sm, md, lg, xl mapping to a consistent step. I like a 4-point base, so 4/8/12/16/24/32. The value isn't the specific numbers; it's that there are only six of them. When your options are finite and named, "what padding goes here" stops being a design decision made 200 times inconsistently and becomes a pick from a short menu.
@immutableclass AppSpacing extends ThemeExtension<AppSpacing> { const AppSpacing({ required this.xs, required this.sm, required this.md, required this.lg, required this.xl, }); final double xs, sm, md, lg, xl; static const standard = AppSpacing(xs: 4, sm: 8, md: 16, lg: 24, xl: 32); @override AppSpacing copyWith({double? xs, double? sm, double? md, double? lg, double? xl}) { return AppSpacing( xs: xs ?? this.xs, sm: sm ?? this.sm, md: md ?? this.md, lg: lg ?? this.lg, xl: xl ?? this.xl, ); } @override AppSpacing lerp(AppSpacing? other, double t) => other == null ? this : other;}Typography is the same idea: a set of named roles — headingLarge, body, caption — each a full TextStyle, not a font size you pair with a weight at the call site and hope you got the pairing right. Once all three of these exist, a raw SizedBox(height: 13) or a naked TextStyle(fontSize: 15) in a code review is an obvious red flag. Not because someone memorized a rule, but because it visibly doesn't come from the menu everyone else is ordering from.
A small but real payoff of centralizing typography: accessibility. When every text style flows through AppText, respecting the OS text-scale factor, supporting Dynamic Type, or bumping the whole app up a size for a low-vision audit is one edit in one file. When font sizes are scattered across two hundred call sites, "make the app respect large text" becomes a multi-sprint archaeology project.
A reusable component is only reusable if its API expresses intent, not implementation. This is where most in-house design systems quietly fail. Someone builds a button that takes a Color, a padding, a borderRadius, and a textStyle, ships it, and feels good because it's "flexible." That's not a component. It's a Container with extra steps and a friendlier name. Every caller will configure it slightly differently, and within a quarter you'll have fourteen visually distinct buttons all built from the same "reusable" class. Flexibility at the API surface is how inconsistency re-enters through the front door after you locked the back one.
The right version takes a variant and a size, and derives everything else from tokens. The caller says what they mean; the component decides how it looks.
enum ButtonVariant { primary, secondary, danger }enum ButtonSize { small, medium }class AppButton extends StatelessWidget { const AppButton({ super.key, required this.label, required this.onPressed, this.variant = ButtonVariant.primary, this.size = ButtonSize.medium, this.isLoading = false, this.icon, }); final String label; final VoidCallback? onPressed; final ButtonVariant variant; final ButtonSize size; final bool isLoading; final IconData? icon; @override Widget build(BuildContext context) { final colors = context.colors; final space = context.space; final (bg, fg) = switch (variant) { ButtonVariant.primary => (colors.primary, Colors.white), ButtonVariant.secondary => (colors.muted, colors.onSurface), ButtonVariant.danger => (colors.error, Colors.white), }; final padding = switch (size) { ButtonSize.small => EdgeInsets.symmetric(horizontal: space.md, vertical: space.xs), ButtonSize.medium => EdgeInsets.symmetric(horizontal: space.lg, vertical: space.sm), }; return FilledButton( onPressed: isLoading ? null : onPressed, style: FilledButton.styleFrom( backgroundColor: bg, foregroundColor: fg, padding: padding, ), child: isLoading ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[Icon(icon, size: 18), SizedBox(width: space.xs)], Text(label), ], ), ); }}There is no way to make this button the wrong color. That's not a limitation — that's the entire point. A good component API makes off-system usage impossible, not just discouraged. Discouraged means a comment in a PR. Impossible means the type system won't let you. When a designer introduces a genuinely new variant, you add an enum case — a deliberate, reviewable, one-line act that shows up in a diff — rather than someone quietly passing a one-off hex code at a call site that nobody notices for a year.
Modeling variants as an enum and switching over them exhaustively has a second benefit that pays off precisely at scale: Dart's exhaustiveness checking. Add a ButtonVariant.ghost case and every switch that consumes the enum without a default becomes a compile error until you handle it. The compiler becomes your checklist. That's the difference between a system that grows safely and one where new variants silently fall through to whatever the default branch happened to be.
Here's the tension, and it's real: someone will always show up with a legitimately special screen. A marketing splash, a promo banner, a one-off onboarding flow the CEO personally cares about. The temptation is to add a color override parameter "just for this." Don't. The moment your AppButton accepts a raw color, it accepts a raw color for everyone, forever, and your constraint is gone.
The right move is a separate, clearly-named escape hatch. If a screen genuinely lives outside the system, let it use raw Flutter widgets directly and be honest that it's bespoke. A RawButton or just a FilledButton at the call site is more honest than a system component with a backdoor, because it's visibly not part of the system. Keep your system components pure and let the exceptions look like exceptions.
Tokens and components decay without enforcement. A design system is a garden, not a monument — it needs weeding. A few things that have actually held the line for me across multiple products and a growing team:
analysis_options.yaml rules or a simple CI grep that fails the build on raw Color(0x...), Colors. usage, or magic EdgeInsets numbers outside the design-system directory. The rule doesn't need to be academically perfect. It needs to make the wrong thing noisy. A build that goes red is worth more than a style guide nobody reads. For a first-class version, the custom_lint package lets you write a real analyzer rule that surfaces the violation directly in the IDE, red-underlined, before the code is even committed.# A crude but effective CI gate. Runs before the real build.# Fails if design-system escape hatches appear in feature code.lint-design-tokens: script: - | if grep -rnE "Color\(0x|Colors\.[a-z]|fontSize: [0-9]" lib/features/; then echo "Raw color/size found in feature code. Use design tokens." exit 1 fi
pubspec.yaml. This dependency direction, boring as it sounds, is the single structural thing that keeps a system a system instead of a suggestion. Once feature code can leak back into the design package, you no longer have a design package, you have a shared junk drawer. Extracting it as a package also means version control, a changelog, and — if you run more than one app — genuine reuse across products.widgetbook-style gallery route, or use the widgetbook package itself, rendering every component in every variant and both themes. This is not a nice-to-have. It's how a new engineer discovers what already exists instead of copy-pasting a button from whatever screen they happened to open last. Most duplication isn't malicious; it's ignorance of what's available. A catalog is the cure. It also doubles as your visual-regression surface — screenshot the catalog in CI (golden tests are perfect for this) and you catch unintended changes before a user does.AppButton in every variant × size × theme is a small, stable image that only changes when the button genuinely changes. Golden-testing whole feature screens is brittle; golden-testing atoms is precise, and it turns "did this token change ripple somewhere unexpected" into a failing test with a visible diff.I'll be honest about the failure mode on the other side: it's possible to over-build this. On a two-screen MVP that might not exist in three months, a full token layer with three ThemeExtension classes and a widgetbook is procrastination dressed as architecture. The system earns its keep at scale — many screens, multiple engineers, a real time horizon. Early on, a single constants.dart with a dozen named values is a completely respectable "design system," and pretending otherwise is how you spend week one building infrastructure for an app that hasn't proven it deserves to exist yet. Build the constraint when the drift becomes real, not before. The skill is reading which phase you're in.
If you want a migration rule of thumb: the token layer becomes worth it the moment more than one engineer is touching UI and the app has crossed roughly a dozen screens, because that's the point where "remembering the values" stops scaling and the search-and-replace risk becomes real. You don't have to boil the ocean either — introduce AppColors first, migrate the highest-traffic screens, and let the lint rule pull the rest of the codebase along over time.
PrimaryButton that hardcodes its own values is a hardcode with a nicer name. Named, semantic design tokens for color, spacing, and typography are the foundation everything else consumes.primary, surface, error — never blue500. Semantic naming is what makes a rebrand a one-line change instead of an archaeology dig.ThemeExtension, not Material's slots. It gives you type-safe, custom semantic tokens with free dark-mode lerping, and a BuildContext extension keeps access readable enough that nobody reaches for the escape hatch.variant and size, derive the rest from tokens, and make off-system usage impossible — not merely discouraged. Lean on Dart's exhaustive switch so new variants are compiler-checked.constants.dart, not three ThemeExtension classes. Build the constraint when the drift becomes real.A Flutter design system is fundamentally about where decisions live. Push color, spacing, and typography decisions into named semantic tokens exposed through ThemeExtension. Push composition decisions into components with intent-based APIs that literally cannot express off-system states. Then use lint rules, a hard import boundary, and a living catalog to make the system the path of least resistance — not the thing you have to remember, but the thing that's simply easier than the alternative.
Do that at the right moment — not too early, not once the drift is already six months deep — and the app that's five times bigger next year still looks like one app. Because the hard part was never drawing the UI. Anyone can draw a nice button. The hard part is keeping a hundred screens, built by a rotating team over three years, still agreeing on what "primary" means. Tokens are how they keep agreeing when nobody's in the room to enforce it.