How to build a Tailwind design system with v4 theme variables
Map tokens to theme variables, encode variants with cva and tailwind-merge, handle dark mode in the token layer, and set guardrails before utilities sprawl.
Tailwind CSS v4 removed the file most Tailwind design systems used to live in. A new v4 project has no tailwind.config.js: your tokens live in CSS, inside an @theme block, and every one of them is also a CSS variable at runtime. That one change makes Tailwind a much better foundation for a design system, and it moves the hard work from configuration to discipline.
A Tailwind design system is a design system whose tokens are defined as Tailwind theme variables, whose components encode their variants as typed class maps, and whose rules limit which utilities product code may use. In Tailwind CSS v4, current at v4.3 as of October 2026, that means @theme for tokens, a variant helper such as class-variance-authority for components, and a short list of enforced guardrails.
Key takeaways
- In Tailwind CSS v4, design tokens are declared in CSS with
@theme, and each theme variable both generates utilities and is emitted as a CSS custom property. - Keep raw brand values as primitives, put meaning in semantic variables, and bridge the two with
@theme inlineso utilities likebg-primaryfollow the active theme. - Encode component variants once with class-variance-authority and merge caller overrides with tailwind-merge, which supports Tailwind v4.0 through v4.3.
- Dark mode belongs in the token layer: swap semantic variable values under a
.darkselector and components need almost nodark:classes. - Resetting the default palette with
--color-*: initialis the single most effective guardrail against utility sprawl.
What changed in Tailwind CSS v4?
Tailwind CSS v4.0, released in January 2025, moved configuration into CSS. You import Tailwind and declare tokens in @theme, and content detection is automatic, so there is no content array to maintain. The default palette moved to OKLCH. Version 4.3, released in May 2026, added scrollbar utilities, @container-size, and default values for functional utilities defined with @utility.
The part that matters for a design system is the namespace model. According to the Tailwind docs, theme variables are special CSS variables that decide which utility classes exist. Each namespace maps to a family of utilities:
--color-*generatesbg-*,text-*,border-*, andfill-*.--font-*generates font family utilities likefont-sans.--spacingdrives every spacing and sizing utility from one base value.--radius-*and--shadow-*generaterounded-*andshadow-*.--breakpoint-*generates responsive variants likemd:*.
Add --color-brand-500 to @theme and bg-brand-500 exists. Remove a namespace and its utilities disappear. Your token file is now your utility API.
How do you map design tokens to Tailwind theme variables?
Use three tiers. The trick is that only some of them should generate utilities.
| Tier | Where it lives | Example | Generates utilities? |
|---|---|---|---|
| Primitive | @theme (or a private :root block) | --color-indigo-600: oklch(...) | Only if product code may use it |
| Semantic | :root and .dark | --primary, --primary-foreground, --border | No, these hold the active value |
| Bridge | @theme inline | --color-primary: var(--primary) | Yes: bg-primary, text-primary-foreground |
The Tailwind docs draw the line plainly: use @theme when a token should map to a utility class, and :root for variables that should not. The inline keyword matters for the bridge tier. Without it, a variable that references another variable resolves where it is defined, which can break theming when values change lower in the tree. With @theme inline, the utility emits the reference itself, so bg-primary always reads the current --primary.
This is the pattern shadcn/ui documents for its theme: semantic background and foreground pairs such as primary and primary-foreground, defined under :root and .dark, then exposed to Tailwind through @theme inline. Adding a new color is three steps: define it in both modes, bridge it, and use the utility.
Because theme files are plain CSS, your token layer can live in its own package and be imported by every app with a single @import.
How should you build component variants?
Utilities describe one element at a time. A design system needs named variants: a button that is default, secondary, or destructive, in sm, md, or lg. Class-variance-authority, or cva, turns that into a typed function. You give it base classes, a variants object, compoundVariants for combinations such as primary and large, and defaultVariants for when a prop is omitted.
Callers will still pass a className. That is where conflicts appear: the button's px-4 and the caller's px-6 both land on the element, and the winner depends on stylesheet order, not intent. tailwind-merge fixes this by keeping the last class in each conflicting group, so twMerge('px-2 py-1', 'p-3') returns p-3. Its current major version supports Tailwind v4.0 through v4.3; projects still on v3 should stay on tailwind-merge 2.6.
Set one rule and keep it strict. Allow className for layout, such as margin and width, and treat anything else as a missing variant.
A variant is a design decision with a name, and it belongs in the component, not at the call site.
How does dark mode work in a Tailwind design system?
By default, Tailwind's dark: variant follows the prefers-color-scheme media query. To let people choose, override it in CSS with @custom-variant dark (&:where(.dark, .dark *)); and toggle a dark class on the root element, or target a data-theme attribute instead.
The bigger decision is where dark values live. If components carry bg-white dark:bg-zinc-900, every component encodes two themes and every new theme means editing all of them. If components carry bg-background, and only the semantic variables change under .dark, a theme is a token file. Reserve dark: classes for real exceptions, such as swapping an illustration.
What guardrails stop utility sprawl?
Tailwind makes every value one class away, which is its strength and the reason Tailwind codebases drift. Use the five sprawl guardrails, in this order:
- Reset the palette. Start your theme with
--color-*: initialand define only your own colors. Off-brand classes likebg-red-500then produce no CSS at all, which is stricter than any lint rule. - Semantic utilities in product code. Product code uses
bg-primaryandtext-muted-foreground; primitives stay inside the system package. - No arbitrary values outside the system. Classes like
top-[117px]andbg-[#bada55]are useful escape hatches, and the docs compare them to inline styles. Flag them in code review or with a lint check, and promote repeated ones to tokens. - Variants in cva, overrides through tailwind-merge. Every visual option is a named variant, and
classNameis for layout only. - New utilities through review. When the system needs a utility Tailwind lacks, add it with
@utilityin the system package so it gets variants likehover:for free, and review it like an API change.
Rule one does most of the work. Classes that do not compile cannot spread.
Where Pegasus fits
Pegasus is a Tailwind design system with a design-tool half. Its React components are written in TypeScript and built on shadcn/ui, Base UI primitives, recharts, and Tailwind CSS. A Figma library of 1,540 components carries the same component names, variant structure, and tokens.
The token layer is the core of it: 470 design tokens covering color, type, spacing, radius, and shadow, available as Figma variables in light and dark modes, and one token source themes both the Figma variables and the code's theme object. That is why a rebrand is a token change rather than a redesign, and why Pegasus offers the 14-Day Rebrand Guarantee: if you can't retheme it to your brand within two weeks of buying, you get a full refund. Storybook includes stories and tests for the components, so you can review a retheme in one place.
FAQ
Does Tailwind CSS v4 still need tailwind.config.js?
No. A new Tailwind CSS v4 project configures everything in CSS: you import Tailwind, declare tokens in an @theme block, and add custom utilities and variants with @utility and @custom-variant. Content detection is automatic, so the old content array is gone too.
What is the difference between @theme and :root in Tailwind?
Variables in @theme are theme variables: they generate utility classes and are also emitted as CSS custom properties. Variables in :root are ordinary CSS variables with no utilities attached. In a design system, use :root and .dark for semantic values that change between themes, and @theme inline to expose them as utilities.
Do I need class-variance-authority for a Tailwind design system?
You need some way to name variants, and cva is the most common choice. It maps props such as variant and size to class strings, supports compound variants and defaults, and works with TypeScript. Pair it with tailwind-merge so caller classes override component classes predictably. A hand-written lookup object also works for small libraries.
Sources
- Tailwind CSS v4.0, Tailwind CSS blog (2025)
- Tailwind CSS v4.3, Tailwind CSS blog (2026)
- Theme variables, Tailwind CSS docs (2026)
- Dark mode, Tailwind CSS docs (2026)
- Adding custom styles, Tailwind CSS docs (2026)
- Variants, class-variance-authority docs (2026)
- tailwind-merge, GitHub (2026)
- Theming, shadcn/ui docs (2026)
One system. In Figma and in code.
Pegasus ships 1,540 components, 470 tokens, matching React code, Storybook, and a local MCP server. $149, one-time.
Get the full system