Learn Design Tokens Using CSS Variables and Tailwind Config
AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.
Introduction
This article demonstrates how to make a design tokens file the real source of truth for a Tailwind project, instead of a document that slowly stops matching the code.
Most teams write the tokens into tailwind.config.js as plain values. That works, but the config is build-time — change a value and you rebuild. We wanted the tokens to be live CSS custom properties, so one file controls the running app.
Design Tokens
A design token is a named design decision. Not #193f7e, but "primary brand colour". Not 16px, but "space-4".
The point is that the name goes in your components and the value lives in one place. Change the value, every component follows.
Features
- One file holds every spacing, size, colour, radius and shadow.
- Editing a value changes the live app with no component change.
- Dark mode is the same tokens with different values.
- Tailwind utilities keep working exactly as normal.
With the following steps, you can wire it up.
- Write the tokens as CSS custom properties.
- Point Tailwind at those variables.
- Know which tokens cannot be variables.
- Add semantic colour keys.
- Flip the tokens for dark mode.
Write the Tokens as CSS Custom Properties
frontend/src/tokens.css holds them, imported from styles.css.
:root {
--tk-space-4: 1rem;
--tk-text-md: 1rem;
--tk-bg-base: #f4f6fc;
--tk-text-primary: #0b1220;
--tk-border: #e2e8f0;
--tk-radius-md: 8px;
--tk-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06);
}
Plain CSS. No build step, no preprocessor. Every value the design system defines lives here.
Point Tailwind at Those Variables
This is the part most setups skip. In tailwind.config.js, a Tailwind scale can hold a var().
spacing: {
4: 'var(--tk-space-4)',
// …
},
fontSize: {
base: 'var(--tk-text-md)',
// …
},
Now p-4, gap-4, m-4 and text-base all read from the tokens file at runtime. Every existing utility across every component picks up the change with no edit anywhere.
This was a value-preserving swap for us, which is worth knowing before you try it. Tailwind's default spacing scale is already 4px-based, the same as ours, so replacing the numbers with variables changed nothing visually. If your scale differs from Tailwind's, expect the swap itself to move your layout.
One mismatch we had to map around: our type scale has an extra md step that Tailwind's named scale does not, so Tailwind's keys (xs … 6xl) map one step up into ours. text-7xl, used only by the hero heading, has no equivalent and keeps Tailwind's own value.
Know Which Tokens Cannot Be Variables
Here is the limitation nobody mentions, and you will hit it within an hour.
Tailwind cannot resolve a var() inside a gradient utility or an opacity modifier.
<!-- These do NOT work if the colour is a var() -->
<div class="from-primary-600 to-accent-500">
<div class="bg-primary-500/20">
Tailwind generates those by manipulating the colour value at build time — splitting it into channels for the /20 opacity, or composing gradient stops. It cannot do that to the string var(--tk-primary-600), because at build time it has no idea what colour that is.
So brand colours and radii are copied into the Tailwind scale as static values.
colors: {
primary: { 600: '#193f7e', /* full 50–950 scale */ },
accent: { 500: '#128686', /* … */ },
gold: { 400: '#e5a718', /* … */ },
},
That is a split, and splits are usually bad. This one is defensible because the two groups behave differently. Spacing and type change often during design work and are never used in gradients. Brand colours almost never change and are used in gradients constantly.
Be honest about the cost: brand colours now live in two places, the tokens file and the config. If you change one, change both. We accepted it because the alternative was giving up from-primary-600 everywhere.
Add Semantic Colour Keys
The tokens that are not brand colours get their own Tailwind keys, named by role rather than by shade.
| Role | Tailwind key | Token |
|---|---|---|
| Surfaces | surface-base, surface, surface-raised, surface-sunken |
--tk-bg-* |
| Text | tk-primary, tk-secondary, tk-muted, tk-faint |
--tk-text-* |
| Borders | line, line-strong |
--tk-border* |
So components write text-tk-secondary instead of text-gray-600 dark:text-gray-400.
This is the payoff, and it is bigger than it looks. Because the tokens themselves flip in dark mode, a semantic class needs no dark: variant at all. text-tk-secondary is correct in both themes. Every component loses half its colour classes, and no one can forget the dark variant on a new component, because there is no dark variant to forget.
Flip the Tokens for Dark Mode
Dark mode is class-based, so the override is one block.
:root.dark {
--tk-bg-base: #060d1e;
--tk-text-primary: #e6edf7;
--tk-border: #1e293b;
}
Only surfaces, text and borders are redefined. Brand colours and status colours (success, warning, danger, info) stay the same in both themes, because a success green that changes meaning between themes is worse, not better.
The upstream token spec used [data-theme="dark"]. We adapted it to :root.dark to match Tailwind's darkMode: 'class' and the existing toggle. If you adopt someone else's token file, expect to adapt its theming selector to yours — it is a five-minute change and it is much better than changing your whole app to match the file.
The One Deliberate Exception
Our footer copyright line sits on a permanently dark strip, whatever the site theme. So it uses a fixed text-gray-400, not a token that would flip with the toggle.
That is the right call, and it is worth writing down where you make one. A future reader seeing a raw Tailwind grey in a token-based codebase will assume it is a mistake unless you tell them otherwise.
Conclusion
In this article we learned how to define design tokens as CSS custom properties, wire Tailwind's spacing and font-size scales to them with var(), keep brand colours static because gradients and opacity modifiers need real values, expose semantic colour keys that need no dark: variant, and flip the whole theme by redefining a handful of tokens.
The result is a tokens file that is not documentation. It is the running app.
Reference
Comments
Be the first to comment.