Back to Blog
Frontend15 min readJun 2026

Design Tokens and Theming: One Source of Truth for Every Design Decision

Design tokens turn scattered hex codes and magic numbers into named, layered design values. Learn the three-tier model, build dark mode without touching a single component, and bridge Figma to code.

Design TokensThemingDesign SystemsCSS Variables
SB

Sri Balaji

Founder

On this page

The hardcoded-color problem

Who this is for

Frontend developers who keep copy-pasting the same `#3b82f6` into component after component, and who flinch when a designer says "we're rebranding" or "we need a dark mode." If you've ever run a find-and-replace across a codebase to change one shade of blue, this article is for you.

Picture a real codebase. A button uses #3b82f6. So does a link. So does the focus ring, the active tab, the selected row, the progress bar. The brand color is hardcoded in 200 files. Then the brand evolves to a slightly different blue, and you discover there is no single place to change it, only 200 places, each one a chance to miss a spot or break something.

Design tokens fix this. A token is a named design value, a color, a spacing step, a font size, a border radius, a shadow, stored once and referenced everywhere. Change the token, and every component that points at it updates at once. The hex code lives in exactly one place, and the rest of the app speaks in *names*, not numbers.

A style guide, not sticky notes

A design token is a named, reusable design decision, the single source of truth that both design and code agree on.
A brand style guide that says 'Primary Blue = the one true blue, use it for all calls to action'A semantic token `--color-primary` defined once and referenced by every interactive element
Sticky notes on every desk, each with a slightly different blue scrawled on it`#3b82f6`, `#3a82f7`, `#3b81f5` hardcoded across 200 component files
Reprinting the style guide's color page when the brand changes, everyone reads the new valueEditing one token value and watching the whole app rebrand instantly
A 'dark mode' edition of the style guide with the same labels but different swatchesRe-pointing semantic tokens under `[data-theme=dark]`, same names, new values
Tokens make the design decision a shared vocabulary instead of a number everyone copies.

The three tiers of tokens

The magic of a token system is not having tokens, it is layering them. Mature systems use three tiers, where each tier references the one before it. This indirection is exactly what makes dark mode and rebrands cheap.

namesnamesconsumed bynamessurfaces
--blue-500

Primitive / base

--color-primary

Semantic / alias

--button-bg

Component

Button

Renders the value

--gray-50

Primitive / base

--color-surface

Semantic / alias

Tokens flow left to right: a raw palette value becomes a meaning, which a component consumes.

  1. 1

    Primitive (base) tokens

    The raw palette. `--blue-500: #3b82f6`. These are pure values with no meaning attached, just every shade you might ever reach for. Components must NOT use these directly.

  2. 2

    Semantic (alias) tokens

    Meaning, not value. `--color-primary: var(--blue-500)`. This is the layer that names intent: primary, surface, text, danger, border. This is the layer themes re-point.

  3. 3

    Component tokens

    Optional, most specific. `--button-bg: var(--color-primary)`. Scopes a semantic token to one component so you can tweak a button without touching the global primary.

The rule that makes it all work

Components reference **semantic** (or component) tokens only, never primitives. That single discipline is what lets you swap themes and rebrand without opening a single component file.

Tokens as CSS custom properties

CSS custom properties (CSS variables) are the natural runtime home for tokens. They cascade, they inherit, they can be redefined per-scope, and they update live with no rebuild. Define primitives and semantics on :root.

css
/* tokens.css */

:root {
  /* ---- Tier 1: primitive / base (raw palette) ---- */
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --gray-50:  #f9fafb;
  --gray-900: #111827;
  --white:    #ffffff;
  --space-2:  0.5rem;
  --space-4:  1rem;
  --radius-md: 0.5rem;
  --shadow-1: 0 1px 3px rgb(0 0 0 / 0.12);

  /* ---- Tier 2: semantic / alias (meaning) ---- */
  --color-primary:    var(--blue-500);
  --color-primary-hover: var(--blue-600);
  --color-surface:    var(--white);
  --color-text:       var(--gray-900);
  --color-border:     #e5e7eb;

  /* ---- Tier 3: component (optional, scoped) ---- */
  --button-bg:        var(--color-primary);
  --button-bg-hover:  var(--color-primary-hover);
  --button-fg:        var(--white);
}

Notice the chain: --button-bg points at --color-primary, which points at --blue-500. Three hops, but a real value at the end. Each tier is editable in isolation.

A component that uses ONLY semantic tokens

Here is the payoff. The button never mentions a hex code or a primitive, it speaks entirely in semantic and component tokens. It has no idea what blue is, and it does not need to.

css
/* button.css */

.button {
  background: var(--button-bg);
  color: var(--button-fg);
  padding: var(--space-2) var(--space-4);
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-1);
}

.button:hover {
  background: var(--button-bg-hover);
}

.button--secondary {
  background: var(--color-surface);
  color: var(--color-text);
  border-color: var(--color-border);
}
tsx
// Button.tsx, no styles know about color values
export function Button({
  variant = "primary",
  children,
}: {
  variant?: "primary" | "secondary";
  children: React.ReactNode;
}) {
  const cls = variant === "secondary" ? "button button--secondary" : "button";
  return <button className={cls}>{children}</button>;
}

Why this matters

This component is now **theme-agnostic**. Light mode, dark mode, high-contrast, a second brand, none of them require a single edit here. All the variation lives in the token layer below it.

Dark mode without touching components

Theming is just re-pointing semantic tokens in a new scope. Put a data-theme attribute on the <html> element and override the semantic layer underneath it. Primitives stay the same (the palette does not change); only the *meaning* mapping changes.

css
/* themes.css */

/* Dark: re-map semantics to darker palette entries. */
[data-theme="dark"] {
  --color-surface: var(--gray-900);
  --color-text:    var(--gray-50);
  --color-border:  #374151;
  --color-primary: #60a5fa;       /* a lighter blue reads better on dark */
  --color-primary-hover: #93c5fd;
}

/* High-contrast: maximize separation for low-vision users. */
[data-theme="high-contrast"] {
  --color-surface: #000000;
  --color-text:    #ffffff;
  --color-border:  #ffffff;
  --color-primary: #ffff00;
  --button-fg:     #000000;
}

Switching themes is one line of JavaScript, set the attribute and the cascade does the rest. No re-render of styled components, no class churn, no flash if you set it before paint.

typescript
// theme.ts, flip the whole app's appearance
type Theme = "light" | "dark" | "high-contrast";

export function setTheme(theme: Theme): void {
  if (theme === "light") {
    document.documentElement.removeAttribute("data-theme");
  } else {
    document.documentElement.setAttribute("data-theme", theme);
  }
  localStorage.setItem("theme", theme);
}

// Restore on load, and respect the OS preference as the default.
export function initTheme(): void {
  const saved = localStorage.getItem("theme") as Theme | null;
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
  setTheme(saved ?? (prefersDark ? "dark" : "light"));
}

The button did not change

Re-read the dark and high-contrast blocks above: not one line of `button.css` or `Button.tsx` was touched. The component still says `var(--button-bg)`; the value behind that name simply differs per theme. That is the entire point of the semantic tier.

Multi-brand theming with the same mechanism

If you ship a white-label product or run several brands on one codebase, the exact same trick scales. Add a data-brand attribute and re-point the semantic primary, surface, and font tokens per brand. Components, and even your dark-mode overrides, keep working unchanged.

css
/* brands.css */

[data-brand="acme"] {
  --color-primary: #d97706;        /* Acme purple */
  --color-primary-hover: #b45309;
  --font-brand: "Inter", sans-serif;
}

[data-brand="globex"] {
  --color-primary: #059669;        /* Globex green */
  --color-primary-hover: #047857;
  --font-brand: "Sora", sans-serif;
}

/* Brand and theme compose: dark + acme just stacks attributes. */
[data-theme="dark"][data-brand="acme"] {
  --color-primary: #fbbf24;
}

Brand and theme are orthogonal layers that compose. data-theme="dark" data-brand="acme" gives you Acme's dark palette without a combinatorial explosion of stylesheets, each axis is defined once.

Primitive vs semantic vs component: who edits what

TierWhat it namesWho edits it
Primitive / baseRaw values: `--blue-500`, `--space-4`. No meaning, just the palette and scale.Design-system maintainers, rarely. Adding a new shade or scale step.
Semantic / aliasIntent: `--color-primary`, `--color-surface`, `--color-danger`. Maps meaning to a primitive.Theme authors. This layer is re-pointed per theme and per brand.
ComponentScope: `--button-bg`, `--card-shadow`. A semantic token narrowed to one component.Component owners, for local tweaks without changing global intent.
Each tier has a different audience and a different rate of change, primitives barely move, semantics move per theme, component tokens move per component.

The Figma to Style Dictionary to code pipeline

Tokens should not be hand-typed twice, once in Figma and once in CSS. The industry-standard bridge is Style Dictionary: designers define tokens in Figma (exported as the W3C-format tokens.json), and Style Dictionary transforms that single JSON into CSS variables, iOS, Android, JS, any platform you target.

json
// tokens.json, the design-tool export (W3C draft format)
{
  "color": {
    "blue": { "500": { "$value": "#3b82f6", "$type": "color" } }
  },
  "semantic": {
    "primary": {
      "$value": "{color.blue.500}",
      "$type": "color"
    }
  }
}
json
// config.json, Style Dictionary builds CSS vars from the JSON
{
  "source": ["tokens.json"],
  "platforms": {
    "css": {
      "transformGroup": "css",
      "buildPath": "build/",
      "files": [
        { "destination": "tokens.css", "format": "css/variables" }
      ]
    }
  }
}

One source, many outputs

Run `style-dictionary build` and the `{color.blue.500}` reference resolves into a real `--color-primary` in your generated `tokens.css`. Designers change a value in Figma, you re-export and rebuild, and web plus mobile update together. The JSON, not the CSS, is the true source of truth.

Accessibility: contrast must survive every theme

A theme is only valid if it is readable. WCAG requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text and UI components. A dark theme that re-points --color-text to a dim gray on a dark surface can silently fail this, and tokens make it easy to introduce that bug if you do not check each theme.

Re-check contrast for every theme, not just light

The same semantic name (`--color-text` on `--color-surface`) maps to different value pairs per theme. Each pairing is a separate contrast obligation. Automate it: feed your token pairs to a contrast checker in CI so a dark or high-contrast theme can never ship below 4.5:1. See our deeper guide on [web accessibility (a11y)](/blog/web-accessibility-a11y).

typescript
// contrast.ts, a CI guard over token pairs
function luminance(hex: string): number {
  const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
  const lin = c.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
  return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}

export function ratio(fg: string, bg: string): number {
  const [a, b] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
  return (a + 0.05) / (b + 0.05);
}

// Fail the build if any theme's text/surface pair dips below 4.5:1.
if (ratio("#f9fafb", "#111827") < 4.5) {
  throw new Error("Dark theme text fails WCAG AA");
}

Common mistakes that cost hours

  1. Using primitive tokens directly in components. Writing color: var(--blue-500) in a button defeats the whole system, that component will not respond to dark mode or a rebrand. Always go through a semantic token.
  2. No naming convention. --blue, --brandColor, --primary-color-2 in the same codebase means nobody can guess a token name. Pick a pattern (--color-<intent>-<state>) and enforce it.
  3. Hardcoding values 'just this once.' That one margin-top: 13px becomes the value no token can override. If it is a design decision, it is a token.
  4. Skipping the semantic tier. Aliasing component tokens straight to primitives (--button-bg: var(--blue-500)) reintroduces the rebrand problem one layer up. Keep meaning in the middle.
  5. Theming only colors. Spacing, radius, type scale, and shadow are tokens too. High-contrast mode often needs thicker borders, not just new colors.
  6. Not testing contrast per theme. A theme that looks slick in a screenshot can be unreadable for low-vision users. Gate it in CI.

Takeaways

The whole article in seven lines

  • A design token is a named design value, the single source of truth for one decision.
  • Three tiers: primitive (raw palette) → semantic (meaning) → component (scoped). Each references the one before.
  • Components reference semantic tokens only, never primitives, that discipline is the entire payoff.
  • Theme by re-pointing semantic tokens under `[data-theme=...]`; components never change.
  • Multi-brand uses the same trick with `data-brand`; theme and brand compose as orthogonal layers.
  • Style Dictionary turns one Figma-exported `tokens.json` into CSS, iOS, and Android.
  • Every theme is a fresh contrast obligation, automate the 4.5:1 check in CI.

Where to go next

Tokens are the foundation layer of a design system, the next step is putting them to work inside well-structured, reusable components and choosing how you deliver the CSS.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.