Building a Design Token System with CSS Custom Properties and Tailwind v4

Hardcoded colors feel fine when you're building for yourself. The moment someone else installs your library, they become a problem — every component locked to your brand's blue, with no clean way to change it. CSS custom properties fix this at the root. Set them up once and every component in the library becomes themeable for free.
If you've been following along, you'll notice the Button and Input articles used hardcoded Tailwind classes like bg-blue-600 and border-red-500. That works while you're the only consumer. The moment someone installs your library and their brand color is amber, not blue, they're stuck manually overriding individual components one by one.
That's not a library. That's a collection of components with opinions baked in that you can't change.
We're fixing this now, before it's in 20 components.
What design tokens are
A design token is a named value that represents a design decision — a color, a spacing unit, a border radius, a shadow. Instead of writing bg-orange-500 everywhere, you write bg-primary. The name carries meaning. The value behind it can change without touching the component.
Tailwind v4 makes this particularly elegant. Rather than a tailwind.config.js full of custom color definitions, v4 uses a two-layer system:
- CSS custom properties — the raw token values, defined in
:root @theme inline— a Tailwind v4 directive that maps those custom properties to Tailwind utility classes automatically
This means bg-primary, text-foreground, border-border become real Tailwind classes — with autocomplete, with dark mode variants, with arbitrary value support — without writing a single custom utility by hand.n this library will be built from these eight values and nothing else.
Setting up the token file
Create src/tokens.css. This is the single source of truth for every color, radius, shadow, and font decision in the library:
We're using oklch color values throughout. oklch is the modern color space for UI work — perceptually uniform, which means lightness adjustments look consistent across hues. You can use a tool like oklch.com to pick and adjust values visually.
The dark mode block
Add this directly below the :root block in tokens.css. Dark mode is a complete token override scoped to a .dark class on the root element. Every token that looks different on a dark surface needs an entry here:here:
A few things worth noting about the dark values:
Primary lightens in dark mode. On a light background, oklch(0.6171) has strong contrast against white. On a dark background that same lightness looks muddy — pushing it to oklch(0.6724) restores the visual weight.
Destructive completely changes. In light mode, destructive is a very dark near-black — used as a high-contrast danger tone on light surfaces. In dark mode it becomes a vivid coral red (oklch(0.6368 0.2078 25.33)) that reads clearly against dark backgrounds.
Tokens that don't change don't appear in .dark. Chart colors 1 and 5 are identical in both modes. Font and shadow variables don't change. Only tokens that genuinely differ on a dark surface need a dark override.
The @theme inline block — how Tailwind sees your tokens
Add this directly below the .dark block in tokens.css. This is the piece most token articles skip, and it's the most important part of the Tailwind v4 setup:es your tokens
This is the piece most token articles skip, and it's the most important part of the Tailwind v4 setup. Add this after your :root and .dark blocks:
@theme inline is a Tailwind v4 directive that tells Tailwind to generate utility classes directly from CSS variable references rather than static values. The result: bg-primary, text-foreground, border-border, shadow-md, rounded-lg, font-sans all become real Tailwind classes — with full autocomplete in your editor, with dark mode variant support via dark:, with opacity modifier support like bg-primary/80.
Without this block, your CSS variables exist but Tailwind knows nothing about them. With it, your entire token system becomes first-class Tailwind utilities.
Completing the base layer
Add this at the bottom of tokens.css, after the @theme inline block:
This lives in tokens.css — not a separate file. We have no global.css in this library. Everything token-related lives in one place: tokens.css is imported by styles.css, and that's the entire CSS pipeline.
This base layer ensures every element's default border color and outline come from your tokens, not Tailwind's defaults. The body gets your background and foreground colors automatically — so consumers who import styles.css get a correctly themed baseline without any extra configuration.
Import everything in styles.css
Your final src/styles.css:
The @custom-variant dark line at the top of tokens.css registers dark: as a Tailwind variant scoped to .dark class descendants — so dark:bg-muted works anywhere in your components automatically.
How CVA variants use tokens
With @theme inline in place, CVA variants read exactly like regular Tailwind — because they are:
No hardcoded colors. No var(--primary) syntax. Just bg-primary — a real Tailwind class that responds to dark mode, supports opacity modifiers, and updates when the consumer overrides the token.
Here's the same update applied to Input variants and Input component:
Input.tsx — hint and error paragraphs:
How consumers theme the library
A consumer who wants their own brand colors overrides the raw CSS variables — they never touch component code:never touch component code:
Every Button, every Input focus ring, every active state updates automatically. The library components didn't change. The tokens did. This is the correct model for a themeable library — expose the tokens, not the implementation.
Enabling dark mode
Consumers activate dark mode by adding the dark class to their root element:
As a library author, your responsibility stops here. You've defined what both themes look like through tokens. How and when dark mode activates — a toggle button, a prefers-color-scheme media query, a user preference in localStorage — is the consumer's decision. Every component in the library responds automatically the moment the class is set.
The rule for this series
No hardcoded color values in any component variant file. Every color reference must be a token-backed Tailwind class. If you find yourself writing text-gray-500 in a CVA variant, stop — text-muted-foreground is the token for that. If the token doesn't exist yet, add it to tokens.css first, then use it.
Up next: Building a Textarea component that grows with its content — Textarea shares most of its structure with Input, but adds the auto-resize behaviour that makes it feel native rather than fixed.
Related: Setting up a React component library with Vite, TypeScript, and Tailwind v4 — the Tailwind v4 setup in article 2 is what makes the custom utility classes in this article work. Worth revisiting if anything in the tokens setup didn't behave as expected.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Mika Baumeister on Unsplash

