Skip to content

Tailwind v4 design tokens: one CSS file, two themes

Jaskaran Singh4 min read

Tailwind v4 removed the JavaScript config in favour of a single CSS file. For a project with a real design system — two themes, a fixed brand palette, and component classes — that turns out to be an upgrade, but only if you understand what @theme actually emits.

This is how the token layer in this site is structured, and the mistakes I made getting there.

Why CSS-first configuration

The old tailwind.config.js was a JavaScript object that generated CSS. The new @theme block is CSS that generates CSS. The practical benefit: your tokens are real CSS custom properties at runtime, which means they can be read by inline styles, referenced in component classes, and overridden by a class on <html> — which is exactly what a theme switch needs.

Everything lives in one file, src/app/globals.css.

Two layers: primitives and semantics

The system separates tokens that never change from tokens that flip with the theme.

Primitives are the brand: the volt accent, the ink black, the two greens. They are fixed for both themes:

css
@theme {
  --color-volt: #f7fe62;
  --color-volt-deep: #d2d853;
  --color-ink: #000000;
  --color-shade: #0b0b0b;
  --color-brand: #56a600;
  --color-forest: #0f7a47;
}

Semantic tokens describe roles, and they are plain CSS variables — not @theme tokens — because they change under the .dark class:

css
:root {
  --surface: #ffffff;
  --panel: #fafafa;
  --fg: #111111;
  --fg-muted: #52525b;
  --line: #e2e2e2;
  --focus: #000000;
}

.dark {
  --surface: #0b0b0b;
  --panel: #111113;
  --fg: #f4f4f5;
  --fg-muted: #a1a1aa;
  --line: #27272a;
  --focus: #f7fe62;
}

Components never use primitives directly for surfaces or text. They use bg-surface, text-muted, border-line — so a single class swap re-themes the entire site.

@theme versus @theme inline

Here is the subtle part. To expose the semantic variables as utilities, they are mapped into the theme:

css
@theme inline {
  --color-surface: var(--surface);
  --color-panel: var(--panel);
  --color-card: var(--card);
  --color-fg: var(--fg);
  --color-muted: var(--fg-muted);
  --color-line: var(--line);
}

The inline keyword matters. Without it, Tailwind resolves the variable at build time and utilities reference the theme variable, which can produce stale values when the underlying variable changes across DOM levels. With inline, utilities compile to the underlying var(--surface) directly. For any token that references another variable — and especially any token that changes under .dark — use @theme inline.

Theme switching with a custom variant

Tailwind's dark variant is configured for a class instead of the operating system preference:

css
@custom-variant dark (&:where(.dark, .dark *));

The :where() wrapper keeps specificity at zero, so dark utilities do not outrank explicit light-mode overrides — a small detail that matters in a component library where both are used on the same element.

Then next-themes simply toggles .dark on <html>, and every dark utility on the page follows.

Component classes that keep the system honest

Not everything is a utility. Buttons in this project share a single class helper so links and buttons cannot drift apart:

ts
const base =
  "inline-flex select-none items-center justify-center gap-2 font-medium transition-colors duration-150 cursor-pointer";

const variants: Record<ButtonVariant, string> = {
  dark: "bg-ink text-white hover:bg-neutral-800 dark:bg-volt dark:text-ink dark:hover:bg-white",
  outline:
    "border border-outline-strong bg-surface text-fg hover:border-ink dark:hover:border-volt dark:hover:text-volt",
  // ...
};

export function btn(variant: ButtonVariant = "dark", size: ButtonSize = "default") {
  return cn(base, variants[variant], sizes[size]);
}

One definition, two themes, used by both <button> and <Link>.

Three cascade traps

Display conflicts. hidden lg:inline-flex looks like it should work, and it does — until the element also inherits inline-flex from a base class helper. Two unprefixed display utilities on one element means source order decides the winner, and it is not the order you wrote. My mobile header was 73 pixels wider than the viewport because of exactly this. The fix was to hide a wrapper instead of fighting the utility.

Ungenerated classes fail silently. Writing dark:text-secrets when --color-secrets is not defined in the theme does not error. The class simply does not exist, the base colour wins, and axe reports a contrast failure you cannot explain until you grep the compiled CSS. If a token is in the design system, it belongs in @theme — otherwise the class is a no-op.

Opacity modifiers multiply with the theme. A quote at opacity-85 looks softly muted in light mode, and drops a link at 4.5:1 contrast to 4.04:1. The colour was fine; the alpha was not. When text must pass AA, control the colour, not the opacity.

Verify with axe, not eyeballs

Every one of those traps survived visual review. All three were caught by an automated pass that runs axe-core at two viewports in both themes and asserts zero violations.

Design token systems are exactly the kind of code where the compiler is quiet and the screenshots look fine — so the tests have to look at computed styles, not screenshots. The whole verification script is about eighty lines, and it has found more real bugs in this design system than I did with my eyes.

More posts.