Tribar Studio
← All tutorials
IntermediateDesign SystemsFlutterTooling

One token source, every platform: generating a Flutter theme from design tokens

Engineering Team··40 min

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.

Let's build the impossible together.

Tell us about your vision. We'll architect the full stack — from device to dashboard — and respond within 24 hours with a plan, timeline, and estimate. No BS, no sales pitch — just geometry.

Start a Project

No commitment required. Free initial consultation.