Back to Lab
RAXXO Studios 11 min read No time? Make it a 1 min read

Tailwind v4 Theme: Design Tokens That Actually Scale

CSS
11 min read
TLDR
×
  • Tailwind v4 ships a native @theme directive that replaces tailwind.config.js and makes design tokens first-class CSS.
  • Tokens live in one file, ship as CSS custom properties, and work outside Tailwind utilities too, so non-Tailwind tools can read them.
  • Layering tokens across brand, semantic, and component tiers kept my design system scalable across 14 RAXXO projects.
  • The upgrade cut my config from 280 lines of JS to 110 lines of CSS and removed an entire build step.

When Tailwind v4 shipped the `@theme` directive, I spent a weekend porting every RAXXO project over and writing down what I wished someone had told me before I started. Tailwind v4 design tokens are no longer a JavaScript config that the build tool has to interpret. They are CSS custom properties generated from a real CSS block. That sounds like a small change. It is not. It changes where your tokens live, how other tools read them, and how your design system scales when you have more than one project using the same brand. This post is the field report.

I run 14 projects under the RAXXO Studios brand, plus my design system for remind.me. Every one of them needs the same `#1f1f21` background, the same Outfit font, the same spacing scale of 0/2/4/6/8/12/16/20/24/32/48/64. Before v4 I had a shared npm package exporting a Tailwind config. It worked. It also meant Figma could not read the tokens without a plugin, my vanilla CSS pages could not use them without a JS build, and every upgrade required syncing three things. With v4 I rewrote the shared package to export one CSS file with an `@theme` block. Every project imports it. Every project reads the same variables. The build step is gone.

What @theme Actually Is

Tailwind v4 parses a CSS block called `@theme` at build time and uses it to generate two things: the utility classes (`bg-brand`, `text-accent`, `spacing-4`) and a set of CSS custom properties that those utilities resolve to. The key idea is that the CSS variables are the primary artifact. The utilities are a convenient accessor on top. If you need to use a token outside of Tailwind, the variable is right there in `:root`.

Here is the shape. This is the top of my shared tokens file, stripped down to the essentials.


@import "tailwindcss";

@theme {
  --font-sans: "Outfit", system-ui, sans-serif;

  --color-bg: #1f1f21;
  --color-text: #f5f5f7;
  --color-accent: #e3fc02;
  --color-muted: #8a8a92;
  --color-border: rgba(255, 255, 255, 0.08);

  --spacing-1: 0.125rem;
  --spacing-2: 0.25rem;
  --spacing-3: 0.375rem;
  --spacing-4: 0.5rem;
  --spacing-6: 0.75rem;
  --spacing-8: 1rem;
  --spacing-12: 1.5rem;

  --radius-pill: 9999px;
  --radius-card: 1.25rem;
  --radius-sm: 0.625rem;
}

Tailwind reads this and does two things. It generates utility classes like `bg-bg`, `text-text`, `text-accent`, `gap-4`, `rounded-pill`, plus it injects every token into `:root` as `--color-bg`, `--color-text`, `--spacing-4`, and so on. I can use the utility `bg-bg` inside a component, and I can use `var(--color-bg)` inside a custom CSS file, and they resolve to the same thing. No build bridge needed.

The naming convention is strict and I learned it the hard way. A variable named `--color-brand-lime` generates utilities `bg-brand-lime`, `text-brand-lime`, `border-brand-lime`. The prefix before the second dash (`--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`) tells Tailwind which utility family the token belongs to. If you write `--brand-lime` without the `color` prefix, no color utility is generated. If you write `--color-brand-lime-extra-punch`, it works, but you get a long class name. Naming discipline pays off.

Three Tiers: Why I Stopped Flattening My Tokens

My first attempt at a shared theme was one giant `@theme` block with every color, font, and spacing value my brand uses. It worked for one project. It fell apart when I tried to reuse the same tokens for a product page, a dashboard, and a docs site. Each surface needed different semantic mappings while sharing the same primitives.

The fix was layering. I now keep three tiers, each in its own layer:

Tier 1: primitives. The raw values. `--color-lime-500: #e3fc02`. These never appear in component code. They are the palette.

Tier 2: semantic. Named by role. `--color-bg: var(--color-gray-900)`, `--color-accent: var(--color-lime-500)`. These are what components reference. A new surface can override them without touching primitives.

Tier 3: component. Only for things that deviate. `--color-button-primary-bg: var(--color-accent)` is redundant. `--color-input-placeholder: color-mix(in oklch, var(--color-text), transparent 60%)` is not, because it encodes a specific rule.

In practice this maps to three imports:


@import "./tokens/primitives.css";  /* raw palette, spacing scale, font families */
@import "./tokens/semantic.css";    /* role names: bg, text, accent, muted, border */
@import "./tokens/components.css";  /* surface-specific tokens */

A theme switch is a one-file override. The dashboard ships its own `semantic.css` that maps `--color-bg` to a slightly warmer gray, and every component automatically updates. The button, card, input, nav. No component code changes.

This tier discipline is not a Tailwind idea. It comes from Brad Frost's design tokens writing and the work jxnblk has been publishing for years. What Tailwind v4 changed is that the tiers are now all CSS. The same tokens that power Tailwind utilities are the same tokens Figma can read, the same tokens a non-Tailwind CSS file can reference, and the same tokens my Storybook docs can render directly. One source of truth, no sync.

What I Actually Cut From My Config

Here is the before and after, honestly measured. My `tailwind.config.js` in v3 was 280 lines. Half of that was `theme.extend`, a quarter was safelist for dynamic class names, the last quarter was plugin wiring. The v4 equivalent is one CSS file at 110 lines. The safelist is gone because v4 parses the CSS in my templates and picks up dynamic classes. The plugins I kept (`@tailwindcss/typography`) are loaded with a one-line `@plugin` directive. The plugins I dropped (forms, aspect-ratio) are now native in CSS.


@import "tailwindcss";
@plugin "@tailwindcss/typography";

@theme {
  /* tokens here */
}

@utility glass {
  background: color-mix(in oklch, var(--color-bg), transparent 40%);
  backdrop-filter: blur(24px);
  border: 1px solid var(--color-border);
}

@utility container-tight {
  max-width: 48rem;
  margin-inline: auto;
  padding-inline: var(--spacing-6);
}

Two things about `@utility`. First, it is the v4 replacement for `@layer components { .glass { ... } }`. The utility is parsed as a utility, which means modifiers like `hover:glass` and `dark:glass` work. Second, the variants you would have written as plugins (responsive, focus, disabled) come along for free.

The removal I love the most is the JIT build process. In v3 I had to tell Tailwind which files to scan so it could generate utility classes. That was `content: ["./src/*/.tsx"]`. In v4 the CLI scans the project automatically and only generates classes that appear. The `content` array is gone. The generate-only-what-is-used behavior stayed.

One more thing that surprised me. The `@theme` block supports CSS features I thought I would have to work around. I can use `color-mix()` inside a token definition. I can use `oklch()` for perceptually-uniform color interpolation, which I use for my dark UI palette because it keeps tonal contrast consistent across hues. I can reference other tokens with `var()`. This means my semantic layer looks like actual code, not a lookup table.

Where Tailwind v4 Does Not Save You

I want to be honest about the rough edges. This is not a pure upgrade win.

Plugins not yet on v4. A handful of plugins I relied on in v3 have not shipped v4 versions yet. I had to either wait, port them myself, or replace them with `@utility` blocks. The community ones update fast. The corporate ones (I was using one specific UI kit) can be slow. If your design system depends on third-party Tailwind plugins, check compatibility before you start.

Editor tooling lag. VS Code IntelliSense took a month to pick up the v4 format. I went that month autocompleting the old way and hitting class names that did not exist. If your team relies heavily on autocomplete for Tailwind, budget for pain during the upgrade window.

Documentation for advanced patterns is thin. The upgrade guide is good. The reference for `@theme`, `@utility`, and variant definitions is solid. The "here is how to build a multi-brand design system" story is not yet written on the official site. I spent a day reading the Tailwind source code to figure out how `@variant` composes with `@utility`. Worth it, but that is not the kind of time solo makers love to spend.

Migration is not fully automatic. The Tailwind v3-to-v4 codemod handles 80% of config porting. The last 20% is hand work, especially if you have custom plugins, opinionated safelists, or exotic variant usage. Budget a weekend per project if the project is non-trivial.

No content safelist means dynamic class names die silently. In v3 the safelist let me ship class names constructed at runtime (`bg-${color}`). In v4 those class names have to appear as literal strings somewhere in your source so the scanner picks them up. I solved this with a small `@utility` block that defines the dynamic variants explicitly. Not hard. Just a different mental model.

The Multi-Project Workflow That Finally Clicked

My 14 projects live in a single monorepo-adjacent structure. Each project is its own repo, but they all pull tokens from one shared npm package, `@raxxo/brand`. The v4 port let me simplify that package dramatically. Before, the package exported a function that returned a Tailwind config object. Consuming projects had to spread that object into their own config, which meant every project had to understand Tailwind's config shape.

After v4 the package exports one CSS file. Consuming projects add a single line:


/* app.css in any RAXXO project */
@import "@raxxo/brand/tokens.css";
@import "tailwindcss";

That is it. The tokens are now in `:root`, Tailwind picks them up via `@theme`, and every project renders identically. When I bump the brand package to update the accent color from lime to a slightly brighter yellow, every consumer sees the change on their next build. No config migration, no function signature to keep compatible.

The other thing I did not appreciate until I shipped this pattern: Figma can read the same CSS file. Using the Figma Tokens plugin I point at the raw CSS on GitHub and the design tokens update automatically on the design side. One source of truth, two consumers (code and design), zero sync tooling in between. This is the workflow I wanted for years and kept failing to build. v4 made it fall out naturally because the tokens became plain CSS.

Debugging Tips After You Switch

A few small things that burned me and might burn you.

Check that every token starts with the right namespace. If you write `--accent-color: #e3fc02` instead of `--color-accent: #e3fc02`, Tailwind will not generate `bg-accent`. The namespace prefix is load-bearing. I built a one-line check into my CI that greps for any `@theme` block variable missing a known namespace (`--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`) and fails the build. Saved me a week later.

Do not nest @theme. Tailwind expects `@theme` at the top level of a CSS file. Nested inside another rule, it silently does nothing. I wasted an hour on this before I read the source.

The variable prefix is configurable. By default Tailwind prefixes its generated variables with no extra prefix. If you want them namespaced as `--tw-color-bg` to avoid collisions with library CSS, you can set a prefix in a `@theme` config block. I did not need it, but worth knowing.

Dark mode still works. You declare a dark theme by adding `@media (prefers-color-scheme: dark)` or `.dark` selectors that override the tokens. Tailwind picks up the new values. All of my themes are now tiny CSS blocks that override a few semantic tokens. The components never need to know.

Bottom Line

Tailwind v4 made design tokens real CSS in a way they were not before. For a solo maker running 14 projects off one brand, that is the difference between a sync chore every upgrade and a one-file change that propagates everywhere. The first weekend of migration was not free. I hit plugin gaps, editor tooling lag, and one bad afternoon of variant debugging. After that, every new project starts from one CSS import and never defines a color, spacing, or font family in component code again. If your design system outgrew a single `tailwind.config.js` a while ago, v4 is the upgrade that rewards the time. Start with your primitives, layer semantics on top, keep component tokens only where the rule justifies them, and let the CSS variables do the work.

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles