/ sagar_pansuriya
§ TailwindCSS

Design Tokens That Survive a Rebrand with Tailwind CSS v4

Illustration of primitive colour swatches flowing into semantic tokens and a UI card, with a brand switch toggling between two themes

Picture the message landing in your team chat on a Tuesday: the new brand is signed off. The purple is now a deep teal, the accent is a warm coral, corners are a little rounder, and marketing would love it live by Friday. Somewhere in your codebase there are 400 occurrences of bg-indigo-600.

How that week goes depends almost entirely on a decision you made months ago: whether your components talk about colours or about meaning. If every button says bg-indigo-600, you're in for a long find-and-replace and a longer QA session. If every button says bg-primary, you change a handful of lines in one CSS file and go get lunch.

Tailwind CSS v4 makes the second path much easier, because the theme now lives in CSS. This post walks through a token structure I like for Tailwind v4 projects: primitive and semantic tokens, naming that ages well, light and dark mode with CSS variables, multiple brands with data attributes, and a checklist for moving an existing tailwind.config.js across.

What changed in v4: the theme is CSS now

In v3, your design system lived in a JavaScript object. In v4 it lives in your stylesheet, inside an @theme block:

/* resources/css/app.css */
@import "tailwindcss";

@theme {
    --color-teal-600: oklch(0.52 0.1 190);
    --font-display: "Satoshi", ui-sans-serif, system-ui, sans-serif;
    --radius-card: 1rem;
}

Two things happen with each variable in @theme. Tailwind generates utilities from its namespace (--color-* gives you bg-teal-600, text-teal-600 and friends, --font-* gives you font-display, --radius-* gives you rounded-card), and it also emits every token as a real CSS custom property on :root. That second part is what makes theming pleasant: your tokens are available to plain CSS, to inline styles, and to JavaScript, and they can be overridden with ordinary CSS rules.

Primitive tokens vs semantic tokens

The core idea of a rebrand-proof system is two layers of tokens with a strict rule about who can use which.

  • Primitive tokens describe what a value is: teal-600, coral-400, gray-950. They're your raw palette. They have no opinion about where they're used.
  • Semantic tokens describe what a value is for: primary, surface, text-muted, border, danger. Each one points at a primitive.

The rule: components only use semantic tokens. Primitives are only referenced by the semantic layer. When the brand changes, you either swap the primitive values or repoint the semantic tokens, and no component file changes.

Diagram showing primitive colour tokens mapped to semantic tokens, which are used by components; a rebrand only changes the mapping between the first two layers

A rebrand then touches one layer. Dark mode is just a second mapping from semantic to primitive. A second brand is a third mapping. Components never notice.

Setting it up in Tailwind v4

Here is the structure I'd start with. Primitives are plain CSS variables, so they don't generate utilities and nobody can reach for bg-teal-600 in a component. Semantic tokens are registered with Tailwind so they do generate utilities.

/* resources/css/tokens.css */

/* 1. Primitives: raw values, no utilities */
:root {
    --teal-500: oklch(0.6 0.11 190);
    --teal-600: oklch(0.52 0.1 190);
    --teal-700: oklch(0.45 0.09 190);
    --coral-400: oklch(0.74 0.14 35);
    --gray-50: oklch(0.98 0 0);
    --gray-200: oklch(0.9 0 0);
    --gray-600: oklch(0.45 0 0);
    --gray-900: oklch(0.2 0 0);
    --gray-950: oklch(0.14 0 0);
    --white: oklch(1 0 0);
}

/* 2. Semantic mapping: light (default) */
:root {
    --surface: var(--white);
    --surface-raised: var(--gray-50);
    --fg: var(--gray-900);
    --fg-muted: var(--gray-600);
    --line: var(--gray-200);
    --primary: var(--teal-600);
    --primary-hover: var(--teal-700);
    --on-primary: var(--white);
    --accent: var(--coral-400);
}

Then expose the semantic layer to Tailwind in your main stylesheet:

/* resources/css/app.css */
@import "tailwindcss";
@import "./tokens.css";

@theme {
    --color-*: initial;   /* drop the default palette so only our tokens exist */
}

@theme inline {
    --color-surface: var(--surface);
    --color-surface-raised: var(--surface-raised);
    --color-fg: var(--fg);
    --color-fg-muted: var(--fg-muted);
    --color-line: var(--line);
    --color-primary: var(--primary);
    --color-primary-hover: var(--primary-hover);
    --color-on-primary: var(--on-primary);
    --color-accent: var(--accent);
}

Two details matter here.

--color-*: initial clears Tailwind's built-in colour namespace. It's optional, but it's the single best guardrail I know: if bg-indigo-600 doesn't exist, nobody can sneak it into a component. Note that white and black live in the same namespace, so add them back as tokens if you use them, and check your build output once after adding this line.

@theme inline is important when theme variables reference other variables. Without inline, a utility like bg-primary compiles to var(--color-primary), and --color-primary is resolved once on :root. If you later override --primary on a nested element (a dark section, a second brand), the utility wouldn't pick it up. With inline, the utility uses var(--primary) directly, so overrides anywhere in the tree just work.

Components now read like this:

<button class="rounded-lg bg-primary px-4 py-2 text-on-primary hover:bg-primary-hover">
    Start free trial
</button>

<div class="rounded-card border border-line bg-surface-raised p-6 text-fg">
    <p class="text-fg-muted">…</p>
</div>

Naming tokens so they age well

Most token systems fail at naming, not at tooling. A few rules I stick to:

  • Name the role, never the colour. --primary, not --teal. --danger, not --red. The moment a semantic token contains a hue, the next rebrand turns it into a lie.
  • Pair foregrounds with backgrounds. Every fill you can put text on gets an on- partner: primary / on-primary, danger / on-danger. That's how you keep contrast correct when the brand colour flips from dark to light.
  • Keep states explicit. primary-hover is boring, but it's clearer than computing hover colours in every component, and a rebrand can tune it per brand.
  • Resist the long tail. Start with ten to fifteen semantic colours. Add one when a real component needs it, not when a Figma file has a new shade.

The same thinking applies beyond colour. Fonts (--font-display, --font-body), radii (--radius-card, --radius-control) and shadows (--shadow-raised) are brand decisions too. A rounder, softer brand is often just two radius values.

Light and dark mode with the same tokens

Dark mode is a second semantic mapping. Nothing else changes:

/* resources/css/tokens.css */
[data-theme="dark"] {
    --surface: var(--gray-950);
    --surface-raised: var(--gray-900);
    --fg: var(--gray-50);
    --fg-muted: oklch(0.72 0 0);
    --line: oklch(0.3 0 0);
    --primary: var(--teal-500);
    --primary-hover: var(--teal-600);
}

Because components use bg-surface and text-fg, you rarely need the dark: variant at all. When you do (an illustration that needs a different opacity, say), point Tailwind's dark variant at the same attribute:

/* app.css */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

If you'd rather follow the operating system preference, wrap the dark mapping in @media (prefers-color-scheme: dark) on :root:not([data-theme="light"]), so a user who explicitly picks light still gets light. Either way, it's one block of CSS.

Multiple brands with data attributes

Once your components only speak semantic tokens, a second brand costs almost nothing. White-label products, a sub-brand for a new product line, a partner portal: each one is just another mapping.

[data-brand="harbor"] {
    --primary: oklch(0.45 0.12 250);
    --primary-hover: oklch(0.4 0.12 250);
    --accent: oklch(0.8 0.13 85);
}

[data-brand="harbor"][data-theme="dark"] {
    --primary: oklch(0.65 0.12 250);
}

Set the attribute on <html> from your Blade layout, based on a tenant, a domain or a config value:

<html lang="en"
      data-brand="{{ $tenant->brand ?? 'default' }}"
      data-theme="{{ $theme ?? 'light' }}">

Because of @theme inline, you can also scope a brand to part of a page. A preview panel in an admin that shows "this is how your portal will look" is just a div with data-brand on it.

The test for a token system is simple: can you add a brand without opening a single component file? If yes, a rebrand is a CSS change. If no, it's a project.

Back to that Tuesday message

With this structure, the rebrand from the opening looks like this: add the new teal and coral primitives, repoint --primary, --primary-hover and --accent in the light and dark mappings, bump --radius-card, swap the font in --font-display. Then you spend the rest of the afternoon on the parts that genuinely need a human: checking contrast on the new on-primary pairs, looking at charts and illustrations that hard-code colours, and updating the logo. That's realistic for an afternoon. The 400-occurrence find-and-replace is not.

Migrating from tailwind.config.js

If you're on v3 with a JavaScript config, you don't have to do it all at once. Tailwind v4 still supports loading a legacy config with the @config directive, so you can upgrade the toolchain first and move tokens into CSS gradually. The official upgrade tool (npx @tailwindcss/upgrade) handles much of the mechanical work, and it's worth running on a clean branch before doing anything by hand.

A rough mapping of the common config keys:

v3 configv4 @theme namespace
theme.extend.colors--color-*
theme.extend.fontFamily--font-*
theme.extend.borderRadius--radius-*
theme.extend.boxShadow--shadow-*
theme.screens--breakpoint-*

My checklist for the move:

  1. Upgrade on a branch with the official tool, and keep the old config behind @config until the build is green.
  2. Inventory every colour actually used in templates. A quick grep -roE "(bg|text|border|ring)-[a-z]+-[0-9]{2,3}" resources/views shows how much raw palette usage you have.
  3. Define your primitives as plain CSS variables and your semantic tokens in @theme inline.
  4. Replace raw palette classes with semantic ones, component by component. Start with buttons, links, surfaces and borders; they cover most of the UI.
  5. Move dark mode from scattered dark: classes into a [data-theme="dark"] mapping, and point the dark variant at it.
  6. Once raw palette classes are gone, add --color-*: initial so they can't come back.
  7. Delete the JS config and the @config line.
  8. Check contrast for every background / on- pair in both themes before you ship.

The takeaway

Tailwind v4 didn't invent design tokens, but by putting the theme in CSS it removed most of the friction around them. Keep primitives out of components, name semantic tokens by role, use @theme inline so overrides cascade, and treat dark mode and extra brands as more mappings rather than more classes. Then the next "the new brand is signed off" message is a small, boring pull request. Which is exactly what you want it to be.

Discussion