Web Developmentguide12 min read

Tailwind v4 Theme Colors in CSS

@theme moves color tokens into CSS. Fewer JavaScript config files, same discipline about naming.

Tailwind v3 kept every color decision in tailwind.config.js. A designer updated the brand ramp in Figma on Tuesday. Engineering opened a JavaScript file on Wednesday, hunted through theme.extend.colors, resolved a merge conflict in brand.500, rebuilt, and shipped a shade that did not match the variable collection because someone had hard-coded a hex fallback six months earlier.

Tailwind v4 changes the contract. Color tokens live in CSS via the @theme directive. Your stylesheet becomes the source of truth that both Tailwind utilities and hand-written CSS can read. The framework still generates bg-brand-500 and text-muted, but those classes resolve to custom properties you define once, next to the components that consume them, in a format version control diffs cleanly, and in a color space that does not lie about lightness the way HSL ramps do.

This article covers how @theme works, why OKLCH belongs in token definitions, how dark mode fits the v4 model, and how to migrate an existing config without breaking production pages on day one.

What changed in the v4 mental model

Tailwind v4 is CSS-first. You import the framework with @import "tailwindcss". Configuration that used to require JavaScript moves into CSS using @theme, @utility, and @variant. The build tool reads your CSS, discovers theme variables, and generates utilities from their names.

The shift matters more than syntax. v3 exported a theme object from JavaScript. v4 declares CSS variables in a theme block. v3 plugins called theme('colors.brand.500'). v4 uses var(--color-brand-500) everywhere. You can still use a config file for content paths and plugins, but color tokens should not live there anymore. Keeping colors in CSS means designers and engineers diff the same artifact, Figma export pipelines can target theme blocks, and runtime theme switching does not require a rebuild, only a class toggle or media query override.

@theme registers design tokens as custom properties on the root. Tailwind watches the namespace and generates matching utilities automatically. Colors use the --color-* prefix. Tailwind strips the prefix and maps --color-brand-500 to bg-brand-500, text-brand-500, border-brand-500, and related utilities. Other namespaces follow the same pattern: --font-sans becomes font-sans, --spacing-4 becomes padding and margin utilities at that scale.

@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.55 0.18 264);
  --color-surface: oklch(0.98 0.01 260);
  --color-text: oklch(0.22 0.02 260);
  --color-text-muted: oklch(0.55 0.02 260);
  --color-border: oklch(0.88 0.01 260);
}

After registration, plain CSS references the same variables without importing through JavaScript. You can split tokens across files in monorepos where a shared package owns brand colors and each app owns semantic aliases. Tailwind merges theme blocks from the import graph with standard cascade override behavior.

Naming discipline in v4 rewards semantic tokens over primitive dumps. A common v3 anti-pattern extended entire gray and blue scales into config, then let components pick text-gray-600 in one file and text-slate-500 in another for the same caption role. v4 does not prevent that mistake automatically. You prevent it by defining --color-text and --color-text-muted in @theme and codemoding primitives out of component class strings over time. Primitives remain available for illustrations and charts. Semantics are what buttons and forms consume.

Runtime theme switching is a practical advantage easy to understate in migration spreadsheets. When colors lived in JavaScript config, toggling dark mode required rebuilding utilities or maintaining parallel dark: class lists. When colors live in CSS variables inside @theme, toggling dark mode means overriding variables under a selector or media query. The utility class text-text-muted does not change. The value underneath does. That reduces JSX noise and makes token experiments possible in the browser without a dev server restart. Product and design can review dark values in Storybook by flipping data-theme on the canvas root while class names stay stable for QA automation.

Why OKLCH belongs in theme tokens

HSL and RGB make poor token foundations because equal steps in lightness do not produce equal perceived contrast. A blue and a yellow at HSL lightness fifty percent behave differently against white and black backgrounds. Palette generators that loop lightness plus ten ship ramps that look even in Figma and fail in production.

OKLCH fixes the math. Even zero point zero eight steps in L read as even steps to humans. Muted text at OKLCH lightness zero point five five on a surface at zero point nine eight passes WCAG AA for body sizes without per-component tweaking. Dark mode adjustments become predictable: bumping lightness by zero point zero five on a surface token produces a visible elevation step. Gradients between brand stops preserve hue identity when interpolated in OKLCH.

When migrating hex ramps from v3, do not transliterate. Rebuild the scale in OKLCH using the anchor shade, usually five hundred, as the fixed point, then regenerate steps. Direct hex-to-OKLCH conversion preserves old broken relationships.

@theme {
  --color-brand-300: oklch(0.75 0.12 264);
  --color-brand-500: oklch(0.55 0.18 264);
  --color-brand-700: oklch(0.40 0.16 264);
}

When the config migration exposed contrast debt

A fintech dashboard migrated from Tailwind v3 to v4 over two releases. Release one moved colors from tailwind.config.js into @theme with visually matched OKLCH values transliterated from the old hex ramp. Utilities kept the same class names. Visual regression on the homepage passed. Release two removed JavaScript color definitions entirely. QA expanded to settings, reporting, and tax document workflows.

Muted captions failed contrast on raised cards in dark mode, not because @theme was wrong, but because v3 had relied on hundreds of dark:text-gray-400 class pairs that duplicated what semantic tokens should have handled. Moving to variable overrides in @theme removed the duplicate classes without adding dark semantic values. The homepage never used muted captions on tinted panels. Settings used them everywhere.

The remediation added mode-aware @theme overrides inside prefers-color-scheme: dark and [data-theme="dark"] blocks. Surface, text, text-muted, and border tokens received paired values with higher muted lightness in dark mode, brighter borders, and reduced accent chroma. Utilities like text-text-muted and bg-surface picked up new values without class changes. The team deleted six hundred forty-seven dark: color class pairs over three weeks.

Semantic aliases were introduced so components stopped referencing gray-500 primitives. Primitives remained in @theme for charts and marketing illustrations. Semantics like --color-text-muted pointed at gray primitives or held explicit OKLCH values. Figma variable paths were aligned before the next export so color/text/primary mapped to --color-text with a signed table in the token repository.

CI began parsing @theme blocks to verify semantic tokens resolved without broken var() chains and contrast-tested pairs programmatically. The migration’s lesson was that v4’s CSS-first tokens expose naming debt faster than JavaScript config because the diff is readable by design and engineering together. Broken dark mode was always broken. v4 made it visible.

Dark mode, semantics, and migration steps

Tailwind v4 does not ship a single dark-mode strategy. You choose how tokens override. System preference uses @media (prefers-color-scheme: dark) with nested @theme blocks. User toggles scope overrides to [data-theme="dark"]. Every utility class picks up overridden variables without dark:bg-surface-900 pairs when tokens are paired correctly.

Copying light-mode OKLCH values into dark theme blocks without adjusting relationships produces muted text failures, invisible borders, and neon accents on black. Dark surfaces need higher lightness on elevated layers, brighter borders, and accents with reduced chroma. See paired token guidance in Dark Mode Tokens That Fail Quietly before locking dark @theme values.

CSS variable overrides cover solid colors cleanly. Images need dark:opacity adjustments or separate assets. Shadows may need dark:shadow-none plus surface bumps. Third-party embeds ignore your tokens. Configure the dark variant once for edge cases:

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

The goal is token overrides for ninety percent of color needs and dark: for exceptions, not hundreds of duplicate class pairs.

Split primitives from semantics in @theme. Primitives are full ramps used via aliases. Semantics are what components actually use: text, text-muted, accent, danger, success. When the brand refreshes, update the alias target or semantic value once.

Migration is incremental. Audit v3 theme.extend.colors. Mark semantic entries for --color-* tokens, primitives for hidden component usage over time, one-offs for deletion. Create a CSS entry file with @import "tailwindcss" and your theme block. Point the build tool at it. Remove colors from JavaScript config but keep content globs and plugins. Rename tokens to match existing classes or codemod components. Replace theme('colors. in custom CSS with var(--color-*). Run visual regression on dashboards and forms, not only heroes.

Figma collections should map one-to-one to theme names before export. A signed table prevents drift between brand-500 in design and brand-500 in CSS. Export pipelines should emit theme blocks or standalone CSS files your app imports.

Step five of migration is often the longest: deleting legacy JavaScript color config after confidence builds. Keep a shadow period where both config and @theme exist with a CI diff that fails if values diverge. The shadow period catches plugins or internal packages still reading theme.extend.colors from JavaScript. Once the diff stays clean for a release cycle, remove color keys from config and delete the shadow check. Partial migration is the default state for large repos. Plan for it explicitly instead of pretending day-one cutover.

Utility naming quirks in v4 deserve attention during review. --color-text generates text-text unless you name carefully. Some teams prefer --color-foreground to avoid doubled nouns in class strings. The naming choice is cosmetic in CSS but affects developer ergonomics for years. Decide foreground versus text in the signed Figma map before the first @theme merge, not after hundreds of components use text-text in JSX.

Content paths and plugins remain in JavaScript config while colors move to CSS. Document that split in README so new contributors do not resurrect theme.extend.colors because the config file still exists for other concerns. A short comment at the top of tailwind.config.ts pointing to app.css for colors prevents regression during busy weeks when muscle memory types extend before thinking.

What @theme does not solve

Moving colors to CSS does not replace discipline. Too many primitives still ship if you theme every shade instead of semantic roles. Hard-coded hex in SVGs and PNGs bypass tokens. Email templates still need inline styles. @theme does not travel to Gmail.

Plugins and third-party UI kits complicate migration because they may ship their own color assumptions. When you adopt a headless component library, audit whether its examples use bg-gray-100 primitives or semantic tokens. Wrap library components in your token-backed classes during migration rather than forking the library’s CSS. @theme tokens flow into utilities your wrappers reference, keeping the library’s internal palette isolated until you can replace class names incrementally.

Arbitrary bracket values in utilities are how token systems die in v4 as they did in v3. bg-[oklch(0.6_0.2_30)] is valid escape hatch syntax for prototypes. In production pull requests it is a lint failure waiting to happen. Stylelint or ESLint custom rules that flag bracket color notation in className strings pay for themselves the first time a rebrand would have required grep through JSX for one-off OKLCH literals.

Monorepo consumers should import shared @theme blocks from a package and extend with app-specific semantics in local CSS entry files. Document override order explicitly because later @theme definitions win for the same variable name. A shared brand package owning --color-brand-500 and an app owning --color-accent: var(--color-brand-500) is clean separation. Two apps defining different --color-brand-500 values in conflicting import orders is a silent brand fork.

Performance and caching improve when color configuration lives in CSS the Vite or webpack pipeline already processes. Removing JavaScript theme merging reduces config startup time modestly, but the real win is fewer merge conflicts on tailwind.config.js during busy release weeks. Designers open the same theme.css pull request engineers review. Comments land on token values in context with component usage examples in the same diff when you colocate Storybook stories near the CSS entry.

Testing hooks become simpler when tokens are CSS. Parse @theme blocks in CI, verify semantic chains resolve, contrast-test documented pairs, snapshot both color schemes, and fail builds on raw hex in component class strings. The v4 model does not remove those requirements. It makes them implementable without importing Tailwind’s JavaScript config into a Node test harness.

Design tokens in CSS also improve onboarding for engineers who do not live in Tailwind daily. A backend developer fixing a typo in marketing copy can read --color-text-muted in the same app.css file without learning theme.extend merge rules. Frontend developers still own naming discipline, but the artifact is plain CSS variables with OKLCH values humans can reason about in code review without executing JavaScript config mentally.

Cross-repo design systems publishing an npm package of theme.css become simpler when consumers import one file and extend with local @theme aliases. Document which variables are stable public API versus internal primitives. Semver for token packages can treat breaking renames of --color-text as major bumps while chroma tweaks within the same role name are minor bumps. That policy gives downstream apps confidence to import shared theme without surprise renames every quarter.

The win is a single source of truth inside the web stack, diffs designers can read, and OKLCH ramps that behave when someone adds dark mode six months after launch. Tailwind v4’s @theme is not a new color system. It is the place your color system should have lived all along.