Skip to main content

Tailwind CSS v4 Theme

What layer does this token belong in?

tailwind.config.js is gone in v4. Everything lives in CSS. Get the layering wrong and tokens either don't generate utilities, don't respond to themes, or create specificity conflicts.


CSS-First Config

Single entry point replaces @tailwind base/components/utilities, the content array, and PostCSS plugin chains:

@import "tailwindcss";

For Vite projects, use @tailwindcss/vite instead of PostCSS.


Three Token Layers

Three distinct places for variables, each with a different purpose:

LayerDirectiveGenerates UtilitiesTheme-ResponsiveUse For
Static tokens@theme { }YesNoSpacing, fonts, breakpoints, animations
Dynamic tokens@theme inline { }YesYesColors that change per-theme
Raw variables:root / [data-theme]NoYesPer-theme values, non-utility variables

@theme { } -- Static design tokens. Generates utility classes AND CSS custom properties on :root. Use for values that don't change between themes:

@theme {
--font-sans: "Inter", sans-serif;
--font-display: "Satoshi", sans-serif;
--spacing: 0.25rem;
--breakpoint-3xl: 1920px;
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
}

@theme inline { } -- Dynamic/themeable tokens. Inlines var() references directly into generated utilities rather than resolving to static values. Critical for multi-theme support:

@theme inline {
--color-background: var(--app-background);
--color-foreground: var(--app-foreground);
--color-primary: var(--app-primary);
}

:root / [data-theme] -- Raw CSS variables. For values that shouldn't generate utility classes, or per-theme overrides:

:root {
--app-background: oklch(0.98 0.003 247);
--app-foreground: oklch(0.14 0.04 265);
--app-primary: oklch(0.62 0.2 250);
}

[data-theme="dark"] {
--app-background: oklch(0.14 0.04 265);
--app-foreground: oklch(0.98 0.003 247);
--app-primary: oklch(0.72 0.18 250);
}

Multi-Theme Pattern

The community and shadcn/ui have converged on this layered approach:

@import "tailwindcss";

/* 1. Raw CSS variables — per-theme values */
:root {
--app-bg: oklch(0.98 0.003 247);
--app-fg: oklch(0.14 0.04 265);
--app-primary: oklch(0.62 0.2 250);
--app-muted: oklch(0.95 0.01 250);
--app-border: oklch(0.91 0.01 250);
}

[data-theme="dark"] {
--app-bg: oklch(0.14 0.04 265);
--app-fg: oklch(0.98 0.003 247);
--app-primary: oklch(0.72 0.18 250);
--app-muted: oklch(0.22 0.03 265);
--app-border: oklch(0.28 0.04 265);
}

/* 2. Static tokens — don't change between themes */
@theme {
--font-sans: "Inter", sans-serif;
--font-mono: "JetBrains Mono", monospace;
--animate-fade-in: fade-in 0.3s ease-out;
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
}

/* 3. Bridge themeable variables into Tailwind utilities */
@theme inline {
--color-background: var(--app-bg);
--color-foreground: var(--app-fg);
--color-primary: var(--app-primary);
--color-muted: var(--app-muted);
--color-border: var(--app-border);
}

Produces bg-background, text-foreground, border-border etc. All respond to theme changes automatically.


OKLCH Colors

V4's default palette uses OKLCH. Benefits: perceptually uniform lightness, wider gamut (P3 displays), predictable opacity via color-mix().

/* OKLCH format: oklch(lightness chroma hue) */
--color-brand-500: oklch(0.65 0.2 250); /* P3 gamut */
--color-brand-500: #3b82f6; /* Works but loses gamut */

Keep a consistent hue angle across your scale. Vary only lightness and chroma for the 50-950 steps.


Namespace Management

Remove defaults you don't need. Reduces CSS output and prevents off-brand colors:

@theme {
/* Remove all default colors */
--color-*: initial;

/* Remove specific unused palettes */
--color-lime-*: initial;
--color-fuchsia-*: initial;

/* Nuclear: reset entire theme */
--*: initial;

/* Then define exactly what you need */
--spacing: 0.25rem;
--color-brand: oklch(0.65 0.2 250);
}

Dark Mode

Use @custom-variant to control how the dark: prefix works:

/* Class-based (next-themes / manual toggle) */
@custom-variant dark (&:where(.dark, .dark *));

/* Data attribute-based (shadcn/ui pattern) */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

If using the CSS-variable multi-theme pattern, you may not need the dark: variant at all -- your variables handle everything.


Semantic Naming

Name tokens by function, not appearance:

GoodBad
--color-background--color-light-bg
--color-surface--color-white
--color-primary--color-blue-500
--color-destructive--color-red

Design system hierarchy: background, foreground, card, card-foreground, primary, primary-foreground, secondary, secondary-foreground, muted, muted-foreground, accent, accent-foreground, destructive, destructive-foreground, border, input, ring.


Layer Placement

Put theme overrides outside @layer base. The shadcn/ui migration guide specifically calls this out:

/* Correct — top level */
:root { --app-bg: oklch(0.98 0.003 247); }
[data-theme="dark"] { --app-bg: oklch(0.14 0.04 265); }
/* Avoid — specificity issues */
@layer base {
:root { --app-bg: oklch(0.98 0.003 247); }
}

Use @layer base only for element-level resets like body { background-color: var(--color-background); }.


Accent Colors

For user-selectable accent colors, layer an independent accent system on top of your base theme:

[data-accent="blue"]   { --app-primary: oklch(0.62 0.2 250); }
[data-accent="green"] { --app-primary: oklch(0.62 0.17 155); }
[data-accent="orange"] { --app-primary: oklch(0.70 0.17 55); }

Apply data-theme and data-accent independently on <html> for maximum flexibility.


Monorepo Organization

libs/design-system/
src/
theme/
tokens.css <- Raw CSS variables (:root, [data-theme])
tailwind-theme.css <- @theme and @theme inline declarations
animations.css <- @keyframes within @theme
index.css <- @import "tailwindcss"; @import "./theme/..."

Each app imports the design system's CSS for consistent tokens everywhere.


Checklist

  • Single @import "tailwindcss" entry point (no @tailwind directives)
  • Static tokens in @theme { }
  • Themeable tokens in @theme inline { } referencing var()
  • Per-theme values in :root / [data-theme] selectors (outside @layer base)
  • OKLCH color format throughout
  • Semantic token names (function, not appearance)
  • Unused default namespaces cleared with --namespace-*: initial
  • @custom-variant dark configured if using class/attribute dark mode
  • @keyframes defined inside @theme block for animations
  • Browser support confirmed (Safari 16.4+, Chrome 111+, Firefox 128+)

Context