A three-tier token architecture (primitive, semantic, component) with CSS custom properties, Style Dictionary transforms, Tailwind v4 integration, and dark mode that's a token swap instead of a stylesheet rewrite.
Tyler McDaniel
AI Engineer & IBM Business Partner
Every design system starts with a color palette in Figma and ends with --color-primary-500 scattered across 200 components with no one remembering why there are both --blue-600 and --brand-blue and whether they're the same value. I've built three design token systems from scratch — two for startups, one for an enterprise with 40+ consuming applications — and the pattern that survives is always the same: tokens are the API contract between design and engineering, and like any API, they need versioning, governance, and clear boundaries.
Building a design token system isn't about picking colors. It's about building the infrastructure that makes design decisions portable, consistent, and maintainable across platforms, themes, and teams.
A design token is a named design decision. That's it. Not a CSS variable, not a JSON file, not a Figma style — those are all representations of a token. The token itself is the decision: "our primary interactive color is this specific blue."
The [Design Tokens Community Group](https://www.w3.org/community/design-tokens/) (part of W3C) has been working on a specification since 2019. The [Design Tokens Format Module](https://tr.designtokens.org/format/) defines a JSON schema for interoperable tokens. The spec matters because it means tools can agree on a format instead of every design system inventing its own.
A token in the spec format:
{
"color": {
"primary": {
"$value": "#2563eb",
"$type": "color",
"$description": "Primary interactive color. Used for buttons, links, focus rings."
},
"primary-hover": {
"$value": "#1d4ed8",
"$type": "color"
}
},
"spacing": {
"sm": {
"$value": "8px",
"$type": "dimension"
},
"md": {
"$value": "16px",
"$type": "dimension"
},
"lg": {
"$value": "24px",
"$type": "dimension"
}
}
}
The $value, $type, and $description prefixed with $ are the spec's way of distinguishing metadata from group nesting. This is a real spec, not something I made up.
The pattern that scales is three tiers. I didn't invent this — [Salesforce Lightning](https://www.lightningdesignsystem.com/design-tokens/), [Adobe Spectrum](https://spectrum.adobe.com/page/design-tokens/), and most mature systems converge on it:
Raw values with no semantic meaning. These are your palette.
{
"blue": {
"50": { "$value": "#eff6ff", "$type": "color" },
"100": { "$value": "#dbeafe", "$type": "color" },
"200": { "$value": "#bfdbfe", "$type": "color" },
"300": { "$value": "#93c5fd", "$type": "color" },
"400": { "$value": "#60a5fa", "$type": "color" },
"500": { "$value": "#3b82f6", "$type": "color" },
"600": { "$value": "#2563eb", "$type": "color" },
"700": { "$value": "#1d4ed8", "$type": "color" },
"800": { "$value": "#1e40af", "$type": "color" },
"900": { "$value": "#1e3a8a", "$type": "color" }
},
"gray": {
"50": { "$value": "#f9fafb", "$type": "color" },
"900": { "$value": "#111827", "$type": "color" }
}
}
Global tokens should never be used directly in components. They exist to feed the next tier.
Named design decisions that reference global tokens. These carry meaning.
{
"color": {
"bg": {
"default": { "$value": "{gray.50}" },
"surface": { "$value": "#ffffff" },
"inverse": { "$value": "{gray.900}" }
},
"text": {
"default": { "$value": "{gray.900}" },
"muted": { "$value": "{gray.500}" },
"inverse": { "$value": "#ffffff" },
"link": { "$value": "{blue.600}" }
},
"interactive": {
"default": { "$value": "{blue.600}" },
"hover": { "$value": "{blue.700}" },
"active": { "$value": "{blue.800}" },
"focus": { "$value": "{blue.500}" }
},
"border": {
"default": { "$value": "{gray.200}" },
"strong": { "$value": "{gray.400}" }
},
"status": {
"error": { "$value": "#dc2626" },
"warning": { "$value": "#d97706" },
"success": { "$value": "#059669" },
"info": { "$value": "{blue.500}" }
}
}
}
The {blue.600} syntax is a reference (alias) — the spec supports this natively. When you change blue.600 in the global tier, every semantic token referencing it updates automatically.
Semantic tokens are what components consume. A button doesn't know about blue-600. It knows about color.interactive.default.
For large systems with many consuming teams, you sometimes need component-level tokens that override or extend semantic tokens:
{
"button": {
"primary": {
"bg": { "$value": "{color.interactive.default}" },
"bg-hover": { "$value": "{color.interactive.hover}" },
"text": { "$value": "{color.text.inverse}" },
"border-radius": { "$value": "{radius.md}" },
"padding-x": { "$value": "{spacing.lg}" },
"padding-y": { "$value": "{spacing.sm}" }
}
}
}
For most teams, two tiers are enough. The third tier adds governance overhead. Only add it when you have multiple teams consuming your tokens and need isolation between component APIs and the underlying design decisions.
The three-tier architecture makes theming trivial. Dark mode is just a different mapping of semantic tokens to global tokens:
{
"$name": "dark",
"color": {
"bg": {
"default": { "$value": "{gray.900}" },
"surface": { "$value": "{gray.800}" },
"inverse": { "$value": "{gray.50}" }
},
"text": {
"default": { "$value": "{gray.50}" },
"muted": { "$value": "{gray.400}" },
"inverse": { "$value": "{gray.900}" },
"link": { "$value": "{blue.400}" }
},
"interactive": {
"default": { "$value": "{blue.400}" },
"hover": { "$value": "{blue.300}" },
"active": { "$value": "{blue.200}" },
"focus": { "$value": "{blue.400}" }
}
}
}
The global tier stays the same. Only the semantic tier changes. Components don't know they're in dark mode — they use color.text.default and get the right value.
This also extends to brand themes, high-contrast accessibility modes, or per-product customizations. Each theme is a different set of semantic mappings. The shape stays identical; the values change.
Tokens in JSON don't do anything until they're transformed into the format your platform needs. This is where [Style Dictionary](https://amzn.github.io/style-dictionary/) (now maintained by the community as [@tokens-studio/sd-transforms](https://github.com/tokens-studio/sd-transforms)) comes in.
Here's a build configuration that outputs CSS custom properties, TypeScript constants, and iOS Swift code from the same token source:
// build-tokens.mjs
import StyleDictionary from 'style-dictionary';
const sd = new StyleDictionary({
source: ['tokens//*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'dist/css/',
files: [
{
destination: 'tokens.css',
format: 'css/variables',
options: {
selector: ':root',
outputReferences: true, // Preserve alias references
},
},
],
},
cssDark: {
transformGroup: 'css',
buildPath: 'dist/css/',
source: ['tokens/themes/dark.json'],
files: [
{
destination: 'tokens-dark.css',
format: 'css/variables',
options: {
selector: '[data-theme="dark"]',
outputReferences: true,
},
},
],
},
ts: {
transformGroup: 'js',
buildPath: 'dist/ts/',
files: [
{
destination: 'tokens.ts',
format: 'javascript/es6',
},
],
},
ios: {
transformGroup: 'ios-swift',
buildPath: 'dist/ios/',
files: [
{
destination: 'Tokens.swift',
format: 'ios-swift/class.swift',
className: 'DesignTokens',
},
],
},
},
});
await sd.buildAllPlatforms();
Run it:
node build-tokens.mjs
Output dist/css/tokens.css:
:root {
--color-bg-default: var(--gray-50);
--color-bg-surface: #ffffff;
--color-text-default: var(--gray-900);
--color-text-link: var(--blue-600);
--color-interactive-default: var(--blue-600);
--color-interactive-hover: var(--blue-700);
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
}
The outputReferences: true option preserves the alias chain in CSS. --color-interactive-default references --blue-600 via var(), so changing the global token updates everything downstream. This is critical for theming and debugging — you can see the relationship between tokens in DevTools.
If you're using [Tailwind CSS v4](https://tailwindcss.com/docs/v4-beta), tokens map directly to CSS-first configuration. No tailwind.config.js needed:
@import "tailwindcss";
@import "./dist/css/tokens.css";
@theme inline {
--color-primary: var(--color-interactive-default);
--color-primary-hover: var(--color-interactive-hover);
--color-surface: var(--color-bg-surface);
--color-muted: var(--color-text-muted);
--spacing-sm: var(--spacing-sm);
--spacing-md: var(--spacing-md);
--spacing-lg: var(--spacing-lg);
}
Now bg-primary, text-muted, p-md all reference your token values. Theme switching works automatically because the CSS custom properties resolve at runtime.
For more on the Tailwind v4 migration and its CSS-first config model, see [Next.js 16 to Tailwind CSS v4 Migration Guide](https://tostupidtooquit.com/blog/nextjs-16-tailwind-v4-migration-guide).
The technical pipeline is the easy part. Governance is where token systems die.
Rule 1: Tokens are a contract. Changing a semantic token's value is a breaking change for every consumer. Treat it like an API version bump. You wouldn't rename a REST endpoint without a migration path; don't rename color.interactive.default to color.action.primary without one either.
Rule 2: One source of truth. Tokens live in one repo, owned by one team (usually design systems). Consumers import the built output, never the source. If designers update tokens in Figma and engineers update JSON separately, they will drift. Use [Tokens Studio for Figma](https://tokens.studio/) to sync Figma styles to the JSON source, or enforce that JSON is the source and Figma imports from it.
Rule 3: Lint your tokens. Write validation that catches:
import { readFileSync } from 'fs';interface Token {
$value: string;
$type?: string;
}
function validateContrast(foreground: string, background: string): boolean {
// Use a library like 'color2k' or 'culori' for real contrast calculation
// This is a placeholder — real implementation needs WCAG luminance math
return true; // Replace with actual contrast ratio >= 4.5
}
function findOrphanedTokens(
globals: Record<string, Token>,
semantics: Record<string, Token>
): string[] {
const referenced = new Set<string>();
for (const [, token] of Object.entries(semantics)) {
const match = token.$value.match(/\{(.+?)\}/);
if (match) referenced.add(match[1]);
}
return Object.keys(globals).filter((key) => !referenced.has(key));
}
Rule 4: Version and changelog. Publish tokens as a package (@your-org/design-tokens) with [semver](https://semver.org/). Every release gets a changelog. Consumers pin versions and upgrade intentionally.
Not every project needs a token system. If you're a solo developer building a single app, CSS custom properties in one file are fine. Tokens add value when:
For a single [Next.js site with Tailwind](https://tostupidtooquit.com/blog/nextjs-16-tailwind-v4-migration-guide), your @theme block in CSS is already a token system in practice. Don't over-engineer it until the pain is real.
If you're implementing tokens alongside a component library, [CSS Architecture Beyond Utility Classes](https://tostupidtooquit.com/blog/css-architecture-beyond-utility-classes) covers how to structure the CSS layer that consumes these tokens.
---
A real migration story from Next.js 15.1 with Tailwind v3.4 to Next.js 16 with Tailwind v4. Covers the CSS-first config system, OKLCH color conversion, Turbopack landmines, and every class name that changed.
Production TypeScript patterns: const type parameters, satisfies operator, template literal types for API routes, branded types for domain safety, discriminated unions, and the builder pattern with full type inference.
A practical mental model for React Server Components. Covers the two-runtime architecture, serialization boundary rules, composition patterns, streaming with Suspense, caching layers, and common mistakes that break production apps.