Tailwind CSS Handbook v4.3

Theme & Design Tokens

Configure Tailwind v4 with the @theme directive — namespaces, overriding defaults, referencing tokens as CSS variables, and the difference from tailwind.config.js.

Rewritten in v4 Your design tokens now live in CSS. The @theme directive both configures Tailwind and emits real CSS custom properties you can use anywhere.

Overview#

app.css
@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.62 0.19 264);
  --font-display: "Satoshi", sans-serif;
  --breakpoint-3xl: 120rem;
  --ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}

That single block gives you:

  • Utilities — bg-brand-500, text-brand-500, font-display, 3xl:*, ease-snappy
  • CSS variables — var(--color-brand-500) in any hand-written CSS
  • Runtime access — getComputedStyle(document.documentElement).getPropertyValue('--color-brand-500')

One definition, three consumers. That is the point of the redesign.

Namespaces#

A variable's prefix decides which utilities it generates.

NamespaceGenerates
--color-*bg-*, text-*, border-*, ring-*, fill-*, shadow-* colours
--font-*font-sans, font-display
--text-*text-sm, text-xl (font size)
--font-weight-*font-bold, font-medium
--tracking-*tracking-tight, tracking-wide
--leading-*leading-tight, leading-relaxed
--spacing-*p-*, m-*, gap-*, w-*, h-*, inset-*
--breakpoint-*sm:, md:, lg: variants
--container-*max-w-* and @sm: container variants
--radius-*rounded-*
--shadow-*shadow-*
--inset-shadow-*inset-shadow-*
--drop-shadow-*drop-shadow-*
--blur-*blur-*, backdrop-blur-*
--perspective-*perspective-*
--aspect-*aspect-*
--ease-*ease-*
--animate-*animate-*
--tab-size-*tab-* v4.3
--zoom-*zoom-* v4.3

The full table with examples is in the Theme Variable Reference.

Adding tokens#

CSS
@theme {
  /* A colour scale */
  --color-brand-50:  oklch(0.97 0.02 264);
  --color-brand-500: oklch(0.62 0.19 264);
  --color-brand-900: oklch(0.35 0.14 264);

  /* A font size with a paired line height */
  --text-hero: 4rem;
  --text-hero--line-height: 1.05;

  /* An easing curve */
  --ease-fluid: cubic-bezier(0.3, 0, 0, 1);
}

The --text-hero--line-height double-dash suffix attaches a default line height to text-hero, so class="text-hero" sets both.

Overriding and removing#

Override a single default#

CSS
@theme {
  --color-gray-500: oklch(0.55 0.02 260);
}

Remove one value#

CSS
@theme {
  --breakpoint-2xl: initial;
}

Replace an entire namespace#

CSS
@theme {
  --color-*: initial;
  --color-white: #fff;
  --color-ink: #121063;
  --color-tide: #3ab7bf;
}

Now bg-red-500 no longer exists — only your palette does. This is the cleanest way to enforce a closed design system.

Start from nothing#

CSS
@theme {
  --*: initial;
  --spacing: 4px;
  --font-body: Inter, sans-serif;
  --color-lagoon: oklch(0.72 0.11 221.19);
}

The spacing scale#

A single variable drives every spacing-derived utility:

CSS
@theme {
  --spacing: 0.25rem; /* the default */
}

p-4 is calc(var(--spacing) * 4). Changing --spacing to 0.2rem rescales the entire system proportionally.

@theme inline#

Use inline when a token's value references another variable that changes at runtime.

CSS
@theme inline {
  --font-sans: var(--font-inter);
}

Without inline, .font-sans resolves to var(--font-sans), whose own value was captured where it was defined. With inline, the utility emits font-family: var(--font-inter) directly, so it picks up whatever that variable holds at use time.

This matters most with Next.js font loading, where --font-inter is set on a wrapper element:

JSX
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })

export default function RootLayout({ children }) {
  return <html className={inter.variable}>{children}</html>
}

@theme static#

By default Tailwind only emits the CSS variables you actually use. static emits all of them — useful when JavaScript reads tokens that no utility references.

CSS
@theme static {
  --color-chart-1: oklch(0.7 0.15 200);
  --color-chart-2: oklch(0.7 0.15 30);
}
JS
const styles = getComputedStyle(document.documentElement)
const series = [1, 2].map((n) => styles.getPropertyValue(`--color-chart-${n}`))

Using tokens outside utilities#

In hand-written CSS#

CSS
@layer components {
  .prose-callout {
    border-left: 3px solid var(--color-brand-500);
    padding-inline: --spacing(4);
    font-size: var(--text-sm);
    color: var(--color-gray-700);
  }
}

--spacing(4) is a helper function that expands to calc(var(--spacing) * 4).

In arbitrary values#

HTML
<div class="rounded-[calc(var(--radius-xl)-1px)]">…</div>

With the shorthand syntax#

HTML
<div class="bg-(--color-brand-500)">…</div>

That is equivalent to bg-[var(--color-brand-500)].

In inline styles and JS#

HTML
<div style="background-color: var(--color-brand-500)">…</div>
JSX
<motion.div animate={{ backgroundColor: 'var(--color-brand-500)' }} />

Advanced example: a themed design system#

app.css
@import "tailwindcss";

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

/* 1. Primitives — the raw palette */
@theme {
  --color-brand-50:  oklch(0.97 0.02 264);
  --color-brand-100: oklch(0.93 0.05 264);
  --color-brand-500: oklch(0.62 0.19 264);
  --color-brand-600: oklch(0.55 0.19 264);
  --color-brand-900: oklch(0.35 0.14 264);

  --font-display: "Satoshi", ui-sans-serif, sans-serif;
  --radius-card: 0.875rem;
  --ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}

/* 2. Semantics — what the primitives mean */
@theme inline {
  --color-surface: var(--surface);
  --color-content: var(--content);
  --color-accent: var(--accent);
}

:root {
  --surface: var(--color-white);
  --content: var(--color-gray-900);
  --accent: var(--color-brand-600);
}

.dark {
  --surface: var(--color-gray-900);
  --content: var(--color-gray-50);
  --accent: var(--color-brand-100);
}
HTML
<div class="rounded-card bg-surface font-display text-content">
  <button class="bg-accent text-white transition ease-snappy">Continue</button>
</div>

Primitives stay stable; semantics change per theme; components reference only semantics.

Common mistakes#

  • Using theme() in new code. The theme(spacing.12) function still works but is deprecated in favour of var(--spacing) and --spacing().
  • Defining tokens outside @theme. A plain :root { --brand: … } produces a CSS variable but no utility. Only @theme generates classes.
  • Expecting arbitrary names to work. --brand-color: red in @theme generates nothing, because --brand-* is not a namespace. It must be --color-brand: red.
  • Forgetting inline with runtime variables. The classic symptom is a font that never applies.
  • Putting @theme before @import "tailwindcss". The import must come first.