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.

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.
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.

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.
### Do custom properties cost performance.
Mesh Gradients: How to Build One That Does Not Look Like a Screensaver
What a mesh gradient is, how to write one as layered CSS radial-gradients, which generators are worth opening, and the rules that keep text readable on top of it.
Hex, RGB, and HSL: A Guide to Web Color Formats
Understand the differences between Hex, RGB, and HSL color formats. Learn when to use each one and convert between them instantly with free tools.
Create CSS Gradients Fast With Online Generators
CSS gradient generators let you build linear, radial, and conic gradients visually and copy ready-to-use code. Practical examples and tips included.
CSS Custom Properties: The Practical Guide to Variables
Use CSS custom properties (variables) for maintainable, themeable stylesheets. Practical examples with color systems, responsive design, and dark mode.
