// blog/design/
Back to Blog
Design · Published September 1, 2026 · 5 min read · By Toine ·

Update note: Rewritten from experience; dead font-size converter link removed, dark-mode guard added

Design Tokens in CSS: Three Tiers, One Rename, No Build Step

Design Tokens in CSS: Three Tiers, One Rename, No Build Step

This week I darkened three colours on toolforte.com because a contrast check failed. It was three lines, because every button, link and caption reads a variable instead of a hex code. That is the whole argument for design tokens, and CSS custom properties are the way to do it without a build step.

Here is the setup I use: three tiers, a naming rule that survives a rebrand, theming by swapping values, responsive tokens with clamp(), and the one place I let a design tool own the numbers.

* * *

Three tiers, and the middle one does the work

Tier one is the palette. Names describe the value and say nothing about where it is used:

`css :root { --blue-500: #3b82f6; --blue-600: #2563eb; --gray-100: #f3f4f6; --gray-900: #111827; --space-4: 1rem; --space-8: 2rem; --radius-md: 0.375rem; } `

Tier two is semantic. Names describe the job, and they point at tier one:

`css :root { --color-primary: var(--blue-500); --color-primary-hover: var(--blue-600); --color-bg: var(--gray-100); --color-text: var(--gray-900); --space-section: var(--space-8); --radius-card: var(--radius-md); } `

Components only ever read tier two. Dark mode, a white-label brand, a high-contrast setting: all of them are a new set of tier-two values, and no component file changes.

Tier three is per component, and optional:

`css .button { --button-bg: var(--color-primary); --button-bg-hover: var(--color-primary-hover); } .button.is-danger { --button-bg: var(--color-danger); } `

A component token exists so one variant can deviate without a new class for every property. If you find yourself defining tier three for everything, you have recreated tier two with longer names.

I pick the palette values with the colour picker and check every text colour against its background before it becomes a token. A contrast failure caught in tier one never reaches a page.

* * *

Name the job, never the colour

A token name is an API. Rename one and you touch every component that reads it, so get the shape right once: --{category}-{property}-{variant}-{state}.

  • --color-text-primary
  • --color-bg-surface-hover
  • --space-component-gap
  • --font-size-heading-lg

Three rules keep this from rotting.

No visual value in a semantic name. --color-blue belongs in tier one. --color-primary-blue is a tier-two name that breaks the day primary becomes green, and it will.

One scale per category. Spacing is numeric (4, 8, 12, 16) or named (sm, md, lg), not both. Mixed scales are how a team ends up with --space-md and --space-12 meaning the same thing.

State goes last, and every interactive token has the same set: -hover, -active, -disabled, -focus. A component that needs a state you did not define will invent a colour inline, and you are back to hex codes.

Write one line of documentation per token, next to the definition: where it is used and where it is not. A token without that line is a magic number with a nicer name.

Design token documentation showing color and spacing scales with code
Design token documentation showing color and spacing scales with code
* * *

A theme is a set of tier-two values

`css :root { --color-bg: #ffffff; --color-bg-surface: #f9fafb; --color-text: #111827; --color-text-muted: #6b7280; --color-border: #e5e7eb; --color-primary: #3b82f6; }

[data-theme="dark"] { --color-bg: #0f172a; --color-bg-surface: #1e293b; --color-text: #f1f5f9; --color-text-muted: #94a3b8; --color-border: #334155; --color-primary: #60a5fa; } `

Switching is one attribute on the root element:

`javascript document.documentElement.setAttribute('data-theme', 'dark'); `

For the system preference, repeat the dark block inside @media (prefers-color-scheme: dark) and guard it with :root:not([data-theme="light"]), so a visitor who chose light keeps light. Define every token on bare :root first; a token that only exists inside a media query is undefined for everyone else, and var() with no fallback renders as nothing.

Two things I check on every theme. Contrast again, because a muted grey that passes on white fails on navy. And the accent as text: a bright accent that works on a dark ground is often unreadable as a link colour on a light one, so --color-link gets its own value per theme instead of borrowing --color-primary.

* * *

Responsive tokens: clamp first, breakpoints second

Spacing that looks right on a desktop is too generous on a phone, and headings need to shrink. Put the change in the token, not in every component.

`css :root { --space-section: clamp(2rem, 4vw, 4rem); --font-size-heading: clamp(1.75rem, 3vw + 1rem, 2.5rem); } `

clamp() scales between a floor and a ceiling with no breakpoint and no jump. It covers type and spacing for most sites. Keep media-query overrides for layout decisions that really are a switch, like a sidebar that collapses:

`css @media (max-width: 768px) { :root { --layout-sidebar-width: 0px; } } `

For a component that appears both full width and in a narrow column, a container query on its own token is cleaner than a viewport query, because the card does not know or care what the viewport is:

`css .card { --card-padding: 1rem; } @container (min-width: 500px) { .card { --card-padding: 2rem; } } `

Token declarations add lines to the stylesheet and nothing to the rendered size that matters; run production CSS through the CSS minifier and the difference is a few hundred bytes.

Key takeaway

Spacing that looks right on a desktop is too generous on a phone, and headings need to shrink.

* * *

Let one tool own the numbers

Tokens only help when designers and developers read the same file. Two setups work.

Figma variables map one to one onto tokens. The designer defines them, uses them in the design file, and a plugin such as Tokens Studio exports them as JSON. Style Dictionary turns that JSON into CSS custom properties, SCSS, or Swift and Kotlin constants for native apps:

`json { "color": { "primary": { "value": "#3b82f6", "type": "color" }, "text": { "value": "#111827", "type": "color" } } } `

becomes

`css :root { --color-primary: #3b82f6; --color-text: #111827; } `

The generated CSS is committed and nobody edits it by hand. The W3C Design Tokens format, still a draft in 2026, is the JSON shape those tools are converging on; use it for the source file and the export path stays open.

For a site with one developer, which is most sites, the CSS file is the source of truth and Figma follows it. I would rather have the real values in the repository than a pipeline nobody runs.

Side-by-side comparison of light and dark themes using the same token system
Side-by-side comparison of light and dark themes using the same token system
* * *

Three questions that come up

Do custom properties cost performance?

Not at any size a normal site reaches. The browser resolves them in the cascade. Define them on :root, not on thousands of elements, and you will not measure a difference.

Custom properties or SCSS variables?

Custom properties for anything that changes at runtime, which is themes, states and responsive values. SCSS variables for build-time arithmetic, if you still have SCSS. Most new projects need only the first.

How many tokens?

A small system runs fifty to a hundred and fifty. Start with the ones your pages use today; a token defined for a page that does not exist yet is one more name to remember and one more thing to keep in sync.

Key takeaway

### Do custom properties cost performance.