One token source, every platform: generating a Flutter theme from design tokens
Every design system dies the same death: the token file and the app drift apart. Someone needs "just one" shade of blue that is not in the palette, hard-codes it, and six months later the design source of truth is a lie nobody notices.
The fix is not discipline. It is mechanics: one JSON source, generated platform artifacts, and a CI check that makes manual drift impossible to merge. This tutorial builds that loop for Flutter.
1. The token source
Tokens live in one file, structured in layers. Primitives name values; semantic tokens name decisions:
{
"color": {
"primary": {
"500": { "value": "#4F46E5" },
"600": { "value": "#4338CA" }
},
"surface": { "value": "{color.neutral.0}" },
"danger": { "value": "{color.red.600}" }
},
"space": {
"1": { "value": "4px" },
"2": { "value": "8px" },
"4": { "value": "16px" }
},
"radius": {
"md": { "value": "12px" },
"full": { "value": "9999px" }
}
}
The layering rule: app code may only reference semantic tokens. A screen says surface, never neutral.0.
That indirection is what lets a rebrand change one line without touching a hundred widgets.
2. Generate, don't translate by hand
Style Dictionary walks the token tree and emits per-platform artifacts. The config maps token paths to Dart:
// tokens/style-dictionary.config.js
export default {
source: ['tokens/*.json'],
platforms: {
dart: {
transformGroup: 'js',
buildPath: 'apps/app/lib/theme/generated/',
files: [
{
destination: 'tribar_tokens.dart',
format: 'dart/class',
options: {
className: 'TribarTokens',
outputReferences: true,
},
},
],
},
},
};
The generated file is committed — client apps build without running the generator — but it carries a header that forbids editing:
// GENERATED FILE — DO NOT EDIT BY HAND.
// Source: tokens/tribar.tokens.json · Regenerate: pnpm tokens:build
import 'dart:ui';
abstract final class TribarTokens {
static const colorPrimary500 = Color(0xFF4F46E5);
static const colorPrimary600 = Color(0xFF4338CA);
static const colorSurface = Color(0xFFFFFFFF);
static const colorDanger = Color(0xFFDC2626);
static const space1 = 4.0;
static const space2 = 8.0;
static const space4 = 16.0;
static const radiusMd = 12.0;
static const radiusFull = 9999.0;
}
3. Wire the theme once
ThemeData is built from tokens in exactly one place. Widgets never import the generated file directly — they read
the theme:
import 'theme/generated/tribar_tokens.dart';
final theme = ThemeData(
colorScheme: const ColorScheme(
brightness: Brightness.light,
primary: TribarTokens.colorPrimary500,
onPrimary: Colors.white,
secondary: TribarTokens.colorPrimary600,
onSecondary: Colors.white,
error: TribarTokens.colorDanger,
onError: Colors.white,
surface: TribarTokens.colorSurface,
onSurface: Color(0xFF171717),
),
cardTheme: CardTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TribarTokens.radiusMd),
),
),
);
Dart has no CSS custom properties, so constants are the mechanism — but the discipline is the same one CSS variable systems enforce: if a value is not in the theme, it does not exist.
4. Put CI on guard duty
The loop is only real if drift cannot merge. Two checks do it:
- name: Tokens are generated and committed
run: |
pnpm tokens:build
git diff --exit-code -- apps/app/lib/theme/generated
The first check regenerates from source and fails if the committed output differs — someone edited either side without the other. A grep guard catches the classics in review:
# Fail if a widget hard-codes a hex color outside generated/theme files
if grep -rEn "Color\(0x[0-9A-Fa-f]{8}\)" apps/app/lib --include="*.dart" \
| grep -v "theme/generated"; then
echo "Hard-coded colors found — use theme tokens." && exit 1
fi
5. Where this grows
Once the loop holds, more platforms are just more targets: CSS custom properties for the web app, a Tailwind config for marketing surfaces, a Kotlin file for Android-only screens. The generation pipeline does not care — and that is the point. Adding a platform becomes a config entry, not a translation project.
This is also the first component of our Design Engine heading for open release: a structured brief (domain, personality, palette) generating the same token tree this tutorial consumes. Start at Open Source if you want to follow that release.
The one rule
The token file is the only file a human edits. Everything downstream is generated, every platform reads the same source, and CI makes hand-edits fail loudly instead of drifting quietly. Design systems do not stay consistent because everyone is careful — they stay consistent because inconsistency does not compile.