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#
@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.
| Namespace | Generates |
|---|---|
--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#
@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#
@theme {
--color-gray-500: oklch(0.55 0.02 260);
}Remove one value#
@theme {
--breakpoint-2xl: initial;
}Replace an entire namespace#
@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#
@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:
@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.
@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:
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.
@theme static {
--color-chart-1: oklch(0.7 0.15 200);
--color-chart-2: oklch(0.7 0.15 30);
}const styles = getComputedStyle(document.documentElement)
const series = [1, 2].map((n) => styles.getPropertyValue(`--color-chart-${n}`))Using tokens outside utilities#
In hand-written 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#
<div class="rounded-[calc(var(--radius-xl)-1px)]">…</div>With the shorthand syntax#
<div class="bg-(--color-brand-500)">…</div>That is equivalent to bg-[var(--color-brand-500)].
In inline styles and JS#
<div style="background-color: var(--color-brand-500)">…</div><motion.div animate={{ backgroundColor: 'var(--color-brand-500)' }} />Advanced example: a themed design system#
@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);
}<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. Thetheme(spacing.12)function still works but is deprecated in favour ofvar(--spacing)and--spacing(). - Defining tokens outside
@theme. A plain:root { --brand: … }produces a CSS variable but no utility. Only@themegenerates classes. - Expecting arbitrary names to work.
--brand-color: redin@themegenerates nothing, because--brand-*is not a namespace. It must be--color-brand: red. - Forgetting
inlinewith runtime variables. The classic symptom is a font that never applies. - Putting
@themebefore@import "tailwindcss". The import must come first.
Related topics#
- Colors — the default palette
- Custom Utilities — turning tokens into new utilities
- Theme Variable Reference — every namespace
- Functions & Directives —
--spacing(),--alpha(),@config