devShakib

I stopped shipping hex codes and my design system finally scaled

Design tokens in Flutter: why hardcoded hex codes break dark mode, and how a three tier token system with ThemeExtension and Style Dictionary finally scaled.

A client demoed our app to their board on a Friday afternoon, flipped their phone into dark mode to save battery, and half the buttons vanished. White text on a background that was supposed to go dark but didn't. The culprit was a single #1E88E5 I had pasted into a widget eighteen months earlier, back when the app had four screens and no dark mode. By the time it bit us, that same blue lived in at least four places I could find and probably a dozen I couldn't.

That afternoon is the reason I stopped shipping hex codes. Not because hardcoded colors are ugly, but because they are a promise you can't keep. A hex code says "this pixel is exactly this color, forever." A real product needs to say "this pixel is the color of a primary action, whatever that happens to be in this theme, on this platform, this quarter." Those are completely different statements, and the gap between them is where design systems go to die. In Flutter especially — where the same codebase ships to iOS, Android, and increasingly web and desktop — that gap widens with every platform you add.

A flat color palette is not a design token system

Most teams think they've adopted design tokens the moment they move colors into a constants file. I did too. Here's what that looked like on one of our earlier products:

class AppColors {  static const blue = Color(0xFF1E88E5);  static const gray100 = Color(0xFFF5F5F5);  static const gray900 = Color(0xFF212121);  static const red = Color(0xFFE53935);}

This feels like progress. It is not. All you've done is give hex codes nicknames. When dark mode arrives you either add a parallel AppColorsDark class and branch on brightness at every call site, or you start layering opacity hacks. When the client rebrands from blue to teal, you rename blue to teal and break every reference, or you keep a variable called blue that holds a teal value, which is its own special kind of hell.

The problem is that AppColors.blue still describes appearance, not intent. The widget knows what color it wants. It has no idea why. And "why" is the only thing that survives a theme change, a rebrand, or a platform port. A token system that doesn't encode intent is just a palette wearing a lanyard.

It's worth being precise about the vocabulary here, because "design token" gets thrown around loosely. A design token is a named, platform-agnostic decision — a variable that stores a design value (a color, a spacing step, a font size, a radius, a duration) with a name that carries meaning. The name is the whole point. Color(0xFF1E88E5) is a value. color.action.primary is a decision. You can compile a decision into any platform's value; you cannot reverse a raw value back into a decision.

The three tiers that actually make a design system scale

The fix that actually holds — and I mean the structural one, not the cosmetic one — is treating tokens as a three-tier contract. Each tier has exactly one job and is only allowed to reference the tier below it.

The rule that makes the whole thing hold together: references only ever point downward. Components reference semantics, semantics reference primitives, primitives reference nothing. A component may never reach past the semantic layer to grab a primitive, because the moment it does, it has hardcoded an appearance and you're back to #1E88E5 breaking dark mode.

Here's the same color expressed properly, as data rather than as Dart:

{  "primitive": {    "blue":    { "500": "#1E88E5", "700": "#1565C0" },    "teal":    { "500": "#14B8A6" },    "neutral": { "100": "#F5F5F5", "800": "#262626", "900": "#171717" }  },  "semantic": {    "color": {      "action":  { "primary": "{primitive.blue.500}" },      "surface": { "raised":  "{primitive.neutral.100}" },      "text":    { "onAction": "#FFFFFF" }    }  }}

That {primitive.blue.500} is a reference, not a value. Nothing downstream ever sees #1E88E5. It sees color.action.primary. When the board flips to dark mode, I retarget one semantic token and every button, chip, and link that asked for "primary action" follows along. That's the whole trick, and it's boring, which is exactly why it works.

The literal-versus-reference discipline

There's a subtle discipline here worth calling out. Notice that text.onAction above is a literal #FFFFFF, not a reference. That was a deliberate exception, and it turned out to be a mistake I later fixed. On a dark-branded flavor, "on action" text needed to go near-black, but because I'd inlined white, that one screen shipped with invisible button labels for a day. The lesson: if a value can vary between themes, it must be a reference, even when the current theme makes the literal look harmless. The literals belong in exactly one tier — primitives — and nowhere else.

This is the single most common way a token system quietly rots. Someone inlines a value "just this once" because the current theme makes it look fine, and six months later a new theme or a white-label flavor turns that harmless literal into a bug you can't grep for. Treat every literal outside the primitive tier as a defect waiting to happen.

Name for intent, not appearance

This is the part people fight me on, so let me be blunt: the name of a semantic token must not contain a color, a shade number, or a physical description. The second you write gray-100 as a semantic token, you've lied. In dark mode that surface isn't gray-100 anymore, it's neutral-800, and now the name is actively wrong. Every engineer reading surfaceGray100 in dark mode has to hold a contradiction in their head.

Compare the two vocabularies:

| Appearance-based (wrong) | Intent-based (right) |

| --- | --- |

| gray-100, lightGray | surface.raised |

| blue, brandBlue | action.primary |

| darkText | text.primary |

| red, errorRed | feedback.danger |

| borderGray | border.subtle |

| green, successGreen | feedback.success |

The intent-based names read like a spec. surface.raised tells you it's a surface that sits above the base plane, so it should feel slightly elevated, whatever "elevated" means in the current theme. In light mode elevation reads as a lighter card; in dark mode, elevation reads as a lighter card too, which is counterintuitive if you named it gray900. Intent names encode the design decision. Appearance names encode a single snapshot of it.

A quick test I use in code review: if I renamed the theme from "light" to "midnight," would this token name still be true? action.primary survives. brandBlue does not. It's the same test whether you're naming a color, a spacing step, or a motion duration — the name should describe the role, not the reading.

One source of truth, many outputs

Here's where a lot of teams quietly give up. They accept the three tiers, define them beautifully in Figma, and then a human retypes all of it into Dart. Now you have two sources of truth, they drift within a sprint, and the whole contract is worthless because the code and the design no longer agree.

The fix is to make the tokens a single machine-readable file — I use a JSON structure close to the W3C Design Tokens format — and compile it to every target. On our stack that's Style Dictionary as the build step. One tokens.json in, three artifacts out: a Dart ThemeExtension, a CSS variable sheet for the marketing site, and a Figma variables import for the designers.

// style-dictionary.config.jsexport default {  source: ['tokens/**/*.json'],  platforms: {    flutter: {      transformGroup: 'flutter',      buildPath: 'lib/gen/',      files: [{ destination: 'tokens.g.dart', format: 'flutter/class.dart' }],    },    css: {      transformGroup: 'css',      buildPath: 'web/styles/',      files: [{ destination: 'tokens.css', format: 'css/variables' }],    },  },};

Style Dictionary doesn't ship a Flutter target out of the box, so the flutter transform group and the flutter/class.dart format above are ones I registered myself — maybe forty lines of glue that maps a hex string to Color(0xFF...) and emits a ThemeExtension. Write it once, forget it forever. The Dart it spits out is dumb, generated, and never edited by hand — which is the point. If a value is in the generated file, it can be traced back to exactly one line of tokens.json.

// GENERATED. Do not edit.@immutableclass AppTokens extends ThemeExtension<AppTokens> {  const AppTokens({required this.actionPrimary, required this.surfaceRaised});  final Color actionPrimary;  final Color surfaceRaised;  static const light = AppTokens(    actionPrimary: Color(0xFF1E88E5),    surfaceRaised: Color(0xFFF5F5F5),  );  static const dark = AppTokens(    actionPrimary: Color(0xFF1E88E5),    surfaceRaised: Color(0xFF262626),  );  // copyWith and lerp omitted for brevity}

Two details in that generated class matter more than they look. ThemeExtension requires you to implement copyWith and lerp — the generator writes both. lerp is what makes theme transitions animate smoothly: when Flutter crossfades from light to dark, it interpolates each token, so a surfaceRaised that moves from #F5F5F5 to #262626 slides through the intermediate grays instead of snapping. You get that for free precisely because the tokens are real typed fields on a ThemeExtension, not strings you look up by key.

I run the compile step in CI. If someone edits the generated Dart by hand, the next build overwrites it and the diff screams. There's a real cost to this — one more thing in the pipeline, one more tool a new hire has to learn — but it buys you the guarantee that a color changes in exactly one place and lands everywhere. On our team, "everywhere" means an Android app, an iOS app, a web dashboard, and a Figma library. Before this, a rebrand was a two-week manual grep. After, it's a pull request against one file.

Theming as data, not as widget logic

Once tokens compile into a ThemeExtension, the widget code gets shockingly quiet. No component branches on Theme.of(context).brightness. No component knows what "dark" is. It asks for the token it wants and gets whatever the active theme resolved.

@overrideWidget build(BuildContext context) {  final tokens = Theme.of(context).extension<AppTokens>()!;  return DecoratedBox(    decoration: BoxDecoration(color: tokens.surfaceRaised),    child: FilledButton(      style: FilledButton.styleFrom(backgroundColor: tokens.actionPrimary),      onPressed: onTap,      child: const Text('Continue'),    ),  );}

That extension<AppTokens>()! lookup is cheap and O(1) — Flutter stores extensions in a map keyed by type on the ThemeData. If you're calling it a lot in one build method, pull it into a local at the top like I did above rather than reaching through Theme.of(context) repeatedly. A small context extension makes call sites even quieter:

extension TokensX on BuildContext {  AppTokens get tokens => Theme.of(this).extension<AppTokens>()!;}// usage: context.tokens.actionPrimary

Wiring it up is a one-liner per theme:

MaterialApp(  theme: ThemeData.light().copyWith(extensions: [AppTokens.light]),  darkTheme: ThemeData.dark().copyWith(extensions: [AppTokens.dark]),);

The payoff shows up when a client asks for the thing every agency dreads: a white-label build. Same app, different brand, shipped as a separate flavor. Because the brand lives entirely in the semantic layer, a new brand is a new tokens.json variant — retarget action.primary from blue.500 to the client's teal.500, recompile, done. Not a single widget changes. We did exactly this for a partner who wanted their own colors on our platform; it took an afternoon, and most of that afternoon was the designer arguing with themselves about the teal.

The mental model that stuck for my team: theming is a data-swap, not a code path. If you ever find yourself writing if (isDark) inside a widget, a token is missing. That if is the smell. The same rule catches subtler leaks — a hardcoded BorderRadius.circular(8), an inline EdgeInsets.all(16), a magic Duration(milliseconds: 200). Those are all decisions that belong in tokens too. Color is just the loudest offender; spacing, radius, typography, and motion rot the same way.

There's a second-order win I didn't expect: onboarding got faster. A new engineer used to ask "what blue do I use for this button?" and get three different answers depending on who they asked. Now the answer is a token name they can autocomplete, and the design decision is already made. The design system stopped being tribal knowledge and became something the compiler enforces. I've had junior devs ship a themed screen on day two without a single color review comment, which used to be unthinkable.

Migrating a Flutter codebase that's already drowning in hex

Nobody starts clean. My real projects had hundreds of hardcoded colors before I ever wrote the word "token." A big-bang rewrite is how you burn a sprint and ship regressions, so here's the incremental playbook I actually use:

The order matters. Teams that try to design the perfect semantic vocabulary up front stall for months in meetings. Teams that ship the lint rule on day one and migrate screen by screen are done before they've finished arguing about whether it's surface.raised or surface.elevated. Ship the guardrail, then let the vocabulary emerge from real screens.

One practical caveat for Flutter migrations: watch MaterialColor swatches and ColorScheme. If you're on Material 3, ColorScheme.fromSeed will generate a full tonal palette for you, and it's tempting to lean on colorScheme.primary as your token layer. It's a reasonable starting point, but ColorScheme is a fixed vocabulary — it has primary, surface, error, and friends, but no surface.raised versus surface.sunken, no action.primary versus action.subtle. I treat ColorScheme as one consumer of my tokens (I populate it from them) rather than as the token system itself. Your semantic layer should be as rich as your product needs, not capped at what Material happened to name.

Where tokens stop and taste takes over

I'll be honest about the limits, because I oversold this to myself at first. Tokens are a system for consistency, and consistency is not the same as good.

Tokens buy you a floor, not a ceiling. They stop the dumb inconsistencies — the four blues that should have been one — so your team's judgment gets spent on the decisions that actually need a human. That's the trade, and it's a good one.

Key takeaways