TL;DR

Tailwind v4 design tokens are CSS variables: keep the values that change between light and dark in :root and .dark, then map them to utilities with @theme inline, so bg-surface or text-muted-foreground follow the theme with no dark: prefix. A complete set names roles instead of colors: surfaces in the order they stack, text levels, tones split into a solid fill and a readable text color, plus radius, elevation, z-index and motion scales. Check every text and surface pair for 4.5:1 contrast in both themes with a script, because a color that passes on white often fails on a dark popover.

Tailwind CSS v4 moved configuration out of tailwind.config.js and into CSS, and many guides stop at the first step: put a few brand colors in @theme and call them tokens. A component library needs more than a palette. It needs Tailwind v4 design tokens for every decision a component makes: which surface sits in front of which, how muted secondary text can get, what a warning uses as a fill and as text, how round a 32px button is next to a 48px one, how a popover casts a shadow on a black page, and which layer a select opens on inside a dialog. This guide is the token system we use in Wingo UI, a React 19 and Tailwind CSS v4 component library, with the real CSS and the contrast numbers behind each choice. We build Wingo UI, so we are not neutral; outside facts link to their sources and are dated.

What are design tokens in Tailwind v4?

Design tokens in Tailwind v4 are CSS custom properties. Tailwind v4 theme variables, the ones you declare inside an @theme block, do two jobs at once: they are CSS variables you can read with var(), and their namespace decides which utilities Tailwind generates. --color-surface creates bg-surface, text-surface and border-surface; --shadow-raised creates shadow-raised. Tailwind CSS v4.0 shipped this CSS-first configuration on January 22, 2025 (release post), and the theme variables docs list the namespaces. We run Tailwind 4.3.3 as of October 2026.

The namespaces a component library uses most:

Namespace Utilities it creates What we keep there
--color-* bg-*, text-*, border-*, ring-*, fill-* surfaces, text levels, tones, borders, the focus ring
--shadow-* shadow-* three elevation levels
--radius-* rounded-* a legacy scale that new code avoids (see the radius section)
--z-index-* z-* seven stacking layers
--ease-*, --transition-duration-* ease-*, duration-* motion curves and durations
--font-* font-* font families
--spacing p-*, m-*, gap-*, w-*, h-* the 4px grid (0.25rem)

--z-index-* and --transition-duration-* are not in the docs table as of October 2026, but Tailwind 4.3 compiles them: our CSS has .z-modal { z-index: var(--z-modal) }.

How should you layer Tailwind v4 design tokens?

Use three layers. Raw values that change between themes go in :root and .dark. A mapping in @theme inline turns them into utilities. Components then paint through a few runtime variables that a prop sets, so a tone or a custom color never needs its own class.

Here is a trimmed but working excerpt of our app/globals.css, with the real values:

@import "tailwindcss"; @custom-variant dark (&:is(.dark *)); :root { --background: oklch(1 0 0); --foreground: oklch(0.3211 0 0); --surface: #ffffff; --popover: oklch(1 0 0); --muted-foreground: oklch(0.52 0.0234 264.3637); --success: #15803d; --success-foreground: #ffffff; --success-soft: #e7f6ec; --success-soft-foreground: #166534; --elevation-raised: 0 0 0 1px rgb(0 0 0 / 0.07), 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 8px -2px rgb(0 0 0 / 0.06); } .dark { --background: oklch(0 0 0); --foreground: oklch(0.9219 0 0); --surface: #1a1a1a; --popover: #262626; --muted-foreground: oklch(0.7155 0 0); --success-soft: #0d2315; --success-soft-foreground: #4ade80; --elevation-raised: inset 0 1px 0 0 rgb(255 255 255 / 0.06), 0 0 0 1px rgb(255 255 255 / 0.08), 0 1px 2px rgb(0 0 0 / 0.5); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-surface: var(--surface); --color-popover: var(--popover); --color-muted-foreground: var(--muted-foreground); --color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-success-soft: var(--success-soft); --color-success-soft-foreground: var(--success-soft-foreground); --shadow-raised: var(--elevation-raised); } 

The success fill and its white text are the same in both themes (5.02:1), so this excerpt declares them only in :root and .dark inherits them. Everything that changes is redeclared under .dark, and a component uses the same classes everywhere:

export function InvoiceRow() { return ( <div className="flex items-center justify-between rounded-2xl bg-surface p-4 text-foreground shadow-raised"> <div> <p className="text-[15px] font-medium">Invoice 1042</p> <p className="text-sm text-muted-foreground">Emma Carter, New York</p> </div> <span className="rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-soft-foreground">Paid</span> </div> ); } 

The raw names follow shadcn/ui's convention on purpose: --background, --card, --popover and --muted-foreground are shadcn names, and the ones shadcn does not have (--surface, --canvas, the --success-* family) use the same name and name-foreground pattern. A project that already has shadcn tokens can take ours without renaming anything.

When do you need Tailwind v4 @theme inline?

Use @theme inline whenever a theme variable points at another variable, which is every token that changes with the theme. The Tailwind docs say it directly: with inline, "the utility class will use the theme variable value instead of referencing the actual theme variable". Here is what that means in compiled CSS:

/* @theme inline { --color-surface: var(--surface); } */ .bg-surface { background-color: var(--surface); } /* @theme { --color-surface: var(--surface); } */ :root, :host { --color-surface: var(--surface); } .bg-surface { background-color: var(--color-surface); } 

The difference shows up below the root. A custom property inherits its computed value, and var() is substituted where the property is declared. In the second version --color-surface is resolved once, on :root, and every element inherits that result. A .dark class on <html> still works, because <html> is the root. A dark section inside a light page (<section className="dark">), a dark preview pane or a data-theme attribute on a container does not: the section inherits the light value that :root already resolved. With inline, bg-surface reads var(--surface) on the element itself, so it picks up the nearest override. The Tailwind docs explain the option with the same kind of parent and child example.

One catch remains with inline: if you write var(--color-surface) yourself, in an arbitrary value like bg-[var(--color-surface)] or in plain CSS, Tailwind 4.3 emits --color-surface: var(--surface) on :root to satisfy it, and that reference is resolved on the root again. Inside components, read the raw variable instead: bg-(--surface) or var(--surface).

Two more rules from the docs: theme variables must sit at the top level, never under a selector or a media query, so per-theme values live outside @theme anyway; and Tailwind only emits the theme variables your code uses, unless you write @theme static. The raw variables in :root always ship, which helps when a chart library reads a token with getComputedStyle.

Which semantic color tokens does a component library need?

Name colors by role, never by hue. A component library needs surfaces in the order they stack, text levels with a contrast floor, translucent tints for hover and fills, a border and a focus ring, and status tones. With semantic color tokens, components never contain bg-gray-100 or text-blue-600, so a theme can change every value without touching a component.

Surfaces, back to front

Token Light Dark Use
background #FFFFFF #000000 the page
canvas #F6F7F9 #0E0E10 the page of an app (dashboards, admin), under white panels
card #EBEBEB #121212 flat gray tiles with no shadow
surface #FFFFFF with shadow-raised #1A1A1A raised panels: list groups, stat cards, forms
popover #FFFFFF with shadow-floating #262626 everything that floats: menus, selects, dialogs, sheets, toasts
primary #000000 #FAFAFA the neutral solid fill and tooltips

In dark mode the surfaces get lighter as they come forward: #000, #121212, #1A1A1A, #262626. In light mode most of them are white, so the shadow does the separating. canvas exists because a dashboard on a white page has nothing to set white cards against; a cool gray page makes every bg-surface panel read as a card.

Text levels

Level Class Use Lowest ratio in our token pairs
Primary text-foreground titles, values, labels 10.60:1 (light card)
Secondary text-muted-foreground descriptions, helper text, timestamps 4.62:1 (light card)
Disabled text-foreground/45 disabled labels only none required
Tone text text-success-soft-foreground and its siblings errors, deltas, links 5.28:1 (brand on the light card)

text-muted-foreground is the floor for anything people must read. The disabled level has no contrast target because WCAG 2.2 success criterion 1.4.3 exempts text in inactive controls, which is why it is never used for anything else.

Tints instead of gray fills

Hover states, field fills, chips, tracks and skeletons use translucent foreground: bg-foreground/5, /7, /10 and /15, plus border-foreground/10 for hairlines. Tailwind 4.3 compiles the opacity modifier to color-mix(), with the plain color as a fallback:

.bg-foreground\/5 { background-color: var(--foreground); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, var(--foreground) 5%, transparent); } } 

A tint is relative to whatever sits behind it, so it moves one step toward the text color on every surface, in both themes. A fixed fill cannot do that. Our dark --muted is #171717: lighter than the black page and darker than the #1A1A1A surface, so the same field would look raised on one and sunken on the other. The full-strength fallback only shows in browsers without color-mix(), which are all below Tailwind's browser baseline of Chrome 111, Safari 16.4 and Firefox 128.

shadcn/ui's secondary and accent stay in our file so ported components keep working, but our --accent is an orange tint, and new components never use either.

How do status colors work without a class per color?

Give each status tone four tokens: a solid fill, the text on that fill, a soft tinted surface, and tone text that reads on the soft surface and on every neutral surface. Then let components paint through five generic variables that one class sets. That is the whole idea behind the free Tones utility.

The four tokens per tone, for brand, success, warning, destructive and info:

--success: #15803d; /* bg-success: buttons, solid badges, progress */ --success-foreground: #ffffff; /* text on the fill */ --success-soft: #e7f6ec; /* bg-success-soft: soft badges, alerts, selected rows */ --success-soft-foreground: #166534; /* colored text on the tint and on any neutral surface */ 

The split between fill and tone text is the easiest rule to skip, and it is where dark mode breaks. Fills are tuned to carry white text, and they make poor text. text-destructive (#DC2626) on our dark popover is 3.13:1; text-destructive-soft-foreground (#FF8A80) on the same surface is 6.63:1. The warning fill is amber (#F5A524) at 2.04:1 against white, so it carries dark text (#2B1700, 8.40:1) and is never used as text on a neutral surface.

Component variables

A component with a tone prop does not map tokens itself. toneClasses[tone] sets five variables, and every variant paints with them:

// lib/tones.ts (excerpt) success: "[--tone:var(--success)] [--tone-foreground:var(--success-foreground)] [--tone-soft:var(--success-soft)] [--tone-soft-foreground:var(--success-soft-foreground)] [--tone-border:color-mix(in_oklab,var(--success)_45%,transparent)]", 

The neutral tone maps --tone to --primary (black in light, near-white in dark) and builds its soft surface from color-mix(in oklab, var(--foreground) 7%, transparent), which is why a neutral soft button works on any surface. A badge built this way:

import type { ComponentProps } from "react"; import { colorVars, toneClasses, type Tone } from "@/lib/tones"; import { cn } from "@/lib/utils"; type StatusBadgeProps = ComponentProps<"span"> & { tone?: Tone; color?: string }; export function StatusBadge({ tone = "neutral", color, className, style, ...props }: StatusBadgeProps) { return ( <span data-slot="status-badge" className={cn( toneClasses[tone], "inline-flex rounded-full bg-(--tone-soft) px-2 py-0.5 text-xs font-medium text-(--tone-soft-foreground)", className, )} style={{ ...colorVars(color), ...style }} {...props} /> ); } 

bg-(--tone-soft) is Tailwind v4's shorthand for bg-[var(--tone-soft)]. The free Badge is the full version of this pattern, with solid, soft and outline variants. colorVars(color) builds the same five variables from any CSS color as an inline style. Its text-on-fill value uses relative color syntax, oklch(from <color> clamp(0, (0.7 - l) * 1000, 1) 0 0): white when the color's OKLCH lightness is below 0.7, black above. As of October 2026, caniuse lists full support for relative color syntax from Chrome 131, Safari 18 and Firefox 133, and partial support before that, so full support arrived later than Tailwind's own baseline. Where a browser cannot compute the value, the declaration is invalid at computed-value time and the text inherits its parent's color. If you support those browsers, force the text color with className="[--tone-foreground:white]".

Live demo: Tones. The original article embeds an interactive demo here (Each row paints one tone three ways from the same five variables, and the last row is built by colorVars from one hex value, #0EA5E9. Switch the site theme: the solid fills stay, while the soft tints and the tone text take their dark values). Try it live on wingo-ui.com or open the Tones docs. Install: npx wingo-ui@latest add tones

The free Button is the reference consumer. Its variant map contains no color at all:

const VARIANTS: Record<ButtonVariant, string> = { solid: "bg-(--tone) text-(--tone-foreground) shadow-raised", soft: "bg-(--tone-soft) text-(--tone-soft-foreground)", outline: "border border-(--tone-border) text-(--tone-soft-foreground)", ghost: "text-(--tone-soft-foreground)", link: "rounded-[4px] text-(--tone-soft-foreground) underline decoration-current/35 decoration-1 underline-offset-4 transition-[color,text-decoration-color] hover:decoration-current", }; 

Six tones times five variants, plus any custom color, from five lines. Hover and press are a layer of currentColor at 8% and 12% opacity, so they also work for every tone without extra classes.

How do you design dark mode with tokens?

Treat dark as a second set of values for the same roles, picked by hand, and switch it with a class. Components keep the same classes in both themes, so almost nothing in a component needs a dark: prefix.

The variant: our tokens, like shadcn/ui's, use @custom-variant dark (&:is(.dark *));. The Tailwind dark mode docs show &:where(.dark, .dark *), which also matches the element that carries the class and adds no specificity. With the class on <html>, both behave the same for normal use. The class itself comes from next-themes, which writes it before the first paint: wrap the app in its ThemeProvider with attribute="class" (the full provider file is in the props section below), and render <html lang="en" suppressHydrationWarning> in the root layout, because next-themes changes the class before React hydrates:

// app/layout.tsx import type { ReactNode } from "react"; import { Providers } from "./providers"; import "./globals.css"; export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <body className="bg-background text-foreground"> <Providers>{children}</Providers> </body> </html> ); } 

What a designed dark theme looks like in practice, from our token file:

  • Surfaces lighten as they come forward. #000 page, #121212 card, #1A1A1A surface, #262626 popover.
  • Fills stay, soft surfaces flip. The brand fill is #D63F00 in both themes, so white text keeps 4.59:1. Its soft surface goes from #FFEFE6 to #2A1509, and its text from #B03400 to #FF8A4C.
  • The neutral fill inverts. primary is black with white text in light mode and #FAFAFA with #171717 in dark, so the neutral solid button keeps 21:1 in light and 17.16:1 in dark.
  • Elevation changes technique. A drop shadow barely shows on black, so dark shadows lean on a lit top edge and a white hairline (see the elevation levels below).
  • The focus ring shifts. --ring is oklch(0.60 0.20 36.93) in light and oklch(0.6617 0.2215 36.93) in dark, deeper on white so a 2px outline keeps 3:1 on white and on the gray card.
  • Some components need an exception. Our soft Card uses bg-card in light but bg-foreground/7 in dark, because the dark --card is the same gray as a dark sidebar or stage and the card would vanish there. An inversion script never makes that call.

How do you check contrast for every token pair?

List the pairs that are meant to be used together, convert both colors to sRGB, compute the WCAG ratio in both themes, and fail the build below 4.5:1. WCAG asks for 4.5:1 for body text and 3:1 for large text (1.4.3), and 3:1 for UI components and focus indicators against what is next to them (1.4.11).

Our check covers 54 pairs per theme, 108 in total, and reports 0 below 4.5:1 as of release 1.7.0. The tightest pairs are brand-foreground on brand at 4.59:1 and muted-foreground on the light card at 4.62:1. Here is a self-contained version you can drop into scripts/ and run with npx tsx scripts/check-contrast.ts. It reads hex and oklch() values (decimal or percent lightness), follows var() references and exits with code 1 on a failure. It expects the token names used in this guide; if yours differ, edit SURFACES, TONES and PAIRS:

// scripts/check-contrast.ts import { readFileSync } from "node:fs"; type Rgb = [number, number, number]; const css = readFileSync("app/globals.css", "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); // every --name: value inside the blocks that start with this selector function vars(selector: string) { const map = new Map<string, string>(); const block = new RegExp(`(?:^|\\n)${selector.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g"); for (const m of css.matchAll(block)) for (const d of m[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) map.set(d[1], d[2].trim()); return map; } export function oklchToRgb(l: number, c: number, h: number): Rgb { const a = c * Math.cos((h * Math.PI) / 180); const b = c * Math.sin((h * Math.PI) / 180); const l3 = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m3 = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s3 = (l - 0.0894841775 * a - 1.291485548 * b) ** 3; const linear = [ 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3, -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3, -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3, ]; // clamp into srgb, then gamma-encode return linear.map((v) => { const x = Math.min(1, Math.max(0, v)); return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055; }) as Rgb; } function parse(value: string, map: Map<string, string>): Rgb { const ref = value.match(/^var\((--[\w-]...
Source:https://dev.to/wingo-ui/tailwind-v4-design-tokens-colors-dark-mode-and-contrast-310c