# Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast

> Tailwind v4 design tokens from a production React library: @theme inline, semantic surfaces, status colors, dark mode and a contrast check you can run in CI.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 19 min
- Canonical: https://wingo-ui.com/blog/tailwind-v4-design-tokens

## 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](https://tailwindcss.com/blog/tailwindcss-v4)), and the [theme variables docs](https://tailwindcss.com/docs/theme) 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:

```css
@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:

```tsx
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](https://tailwindcss.com/docs/theme) 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:

```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](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) 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:

```css
.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](https://tailwindcss.com/docs/compatibility) 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](https://wingo-ui.com/components/tones).

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

```css
--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:

```ts
// 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:

```tsx
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](https://wingo-ui.com/components/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](https://caniuse.com/css-relative-colors) 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): 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 at [Tones](https://wingo-ui.com/components/tones) and install it with `npx wingo-ui@latest add tones`.

The free [Button](https://wingo-ui.com/components/button) is the reference consumer. Its variant map contains no color at all:

```ts
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](https://tailwindcss.com/docs/dark-mode) 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](https://github.com/pacocoursey/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:

```tsx
// 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](https://wingo-ui.com/components/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](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)), and 3:1 for UI components and focus indicators against what is next to them ([1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)).

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`:

```ts
// 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-]+)\)$/);
  if (ref) return parse(map.get(ref[1]) ?? "", map);
  if (value.startsWith("#")) {
    const hex = value.length === 4 ? [...value.slice(1)].map((ch) => ch + ch).join("") : value.slice(1);
    return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as Rgb;
  }
  const ok = value.match(/^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
  if (ok) return oklchToRgb(ok[2] ? +ok[1] / 100 : +ok[1], +ok[3], +ok[4]);
  throw new Error(`cannot parse "${value}"`);
}

const channel = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const luminance = ([r, g, b]: Rgb) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);

export function contrast(fg: Rgb, bg: Rgb) {
  const [hi, lo] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

const SURFACES = ["background", "card", "popover", "surface", "canvas"];
const TONES = ["brand", "success", "warning", "info", "destructive"];
const PAIRS = [
  ["foreground", "background"],
  ...SURFACES.map((s) => ["muted-foreground", s]),
  ...TONES.flatMap((t) => [
    [`${t}-foreground`, t],
    [`${t}-soft-foreground`, `${t}-soft`],
    ...SURFACES.map((s) => [`${t}-soft-foreground`, s]),
  ]),
];

const light = vars(":root");
const themes = { light, dark: new Map([...light, ...vars(".dark")]) };
let failures = 0;
for (const [theme, map] of Object.entries(themes)) {
  for (const [fg, bg] of PAIRS) {
    const ratio = contrast(parse(map.get(`--${fg}`) ?? "", map), parse(map.get(`--${bg}`) ?? "", map));
    if (ratio < 4.5) {
      failures++;
      console.log(`FAIL ${theme}: ${fg} on ${bg} is ${ratio.toFixed(2)}:1`);
    }
  }
}
console.log(`${PAIRS.length * 2} pairs checked, ${failures} below 4.5:1`);
process.exitCode = failures ? 1 : 0;
```

This trimmed version checks 82 pairs. Our full script adds the remaining surface, primary and legacy pairs and composites translucent values over the page first. The pair list is the valuable part: it encodes which text may sit on which surface, so a new token is checked the day it is added. Run it in CI next to the type check.

## Should you write Tailwind v4 OKLCH values or hex?

Use whichever format you can reason about; the format does not change contrast. Tailwind's own palette moved "from `rgb` to `oklch`, taking advantage of the wider gamut" in v4.0 ([release post](https://tailwindcss.com/blog/tailwindcss-v4)), and its [colors reference](https://tailwindcss.com/docs/colors) lists every Tailwind v4 OKLCH value, such as `--color-red-500: oklch(63.7% 0.237 25.331)`. OKLCH earns its place when you derive colors in code, because lightness is its own channel: you can move it while hue and chroma stay put, and the result still looks like the same color.

In our file, neutrals are `oklch()` with zero chroma (`oklch(0.3211 0 0)` is the light foreground), and the tone values are hex, tuned by hand against the contrast check. The [theme generator](https://wingo-ui.com/theme) goes the other way: you pick a base color for each tone (brand, success, warning, danger, info), and it derives that tone's fill, soft surface and text in OKLCH. For light mode it puts the soft surface at lightness 0.965 with chroma capped at 0.035, then walks the tone text's lightness down from 0.6 in 0.005 steps until it reaches 4.6:1 on the soft surface and on every neutral surface. Move `oklchToRgb` and `contrast` from the contrast script into their own module, `scripts/color.ts`, so importing them does not run the check, and the same search is a few lines:

```ts
import { contrast, oklchToRgb } from "./color";

type Rgb = [number, number, number];

// keep hue and chroma, walk lightness until the color reads on every surface it can sit on
export function findReadable(c: number, h: number, surfaces: Rgb[], from: number, to: number, target = 4.6): Rgb {
  const step = from < to ? 0.005 : -0.005;
  for (let l = from; step > 0 ? l <= to : l >= to; l += step) {
    const rgb = oklchToRgb(l, c, h);
    if (Math.min(...surfaces.map((bg) => contrast(rgb, bg))) >= target) return rgb;
  }
  return oklchToRgb(to, c, h);
}
```

The target is 4.6 rather than 4.5 so rounding to hex cannot push a color under the line. One caution: a high-chroma OKLCH value can fall outside sRGB, and what a screen then shows depends on the display and the browser. Keep token values inside sRGB, or at least measure the clamped sRGB value, as `oklchToRgb` does.

## How do radius, elevation, z-index and motion become tokens?

They become scales with a fixed set of steps, each with one job, so a component picks a step instead of a number. A Tailwind v4 design system that only tokenizes color still ends up with one-off shadows and a `z-[9999]` somewhere.

### Radius follows size

Tie radius to the control's size. A 32px and a 48px button only read as the same shape when the larger one has the larger radius. Our controls copy this map from the Button:

```ts
type Scale = "sm" | "md" | "lg";
type ButtonRadius = "none" | "sm" | "md" | "lg" | "full";

const RADII: Record<ButtonRadius, Record<Scale, string>> = {
  none: { sm: "rounded-none", md: "rounded-none", lg: "rounded-none" },
  sm: { sm: "rounded-[6px]", md: "rounded-[8px]", lg: "rounded-[10px]" },
  md: { sm: "rounded-[8px]", md: "rounded-[10px]", lg: "rounded-[12px]" },
  lg: { sm: "rounded-[10px]", md: "rounded-[14px]", lg: "rounded-[16px]" },
  full: { sm: "rounded-full", md: "rounded-full", lg: "rounded-full" },
};
```

Surfaces use `rounded-2xl` (16px) for cards, menus and popovers, `rounded-3xl` (24px) for dialogs, and `rounded-t-[28px]` for bottom sheets. Anything inside a padded surface takes the outer radius minus the padding: a `rounded-2xl p-1.5` menu has `rounded-[10px]` items. Sizes follow the same thinking: 32, 40 and 48px, with the 40px size growing to 44px on touch screens, the rule from our [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components).

The trap is a global radius token. Our file still carries shadcn/ui's older scale for ported components: `--radius: 0.375rem`, `--radius-lg: var(--radius)` and `--radius-sm: calc(var(--radius) - 4px)`. That remaps Tailwind's `rounded-sm`, `rounded-md`, `rounded-lg` and `rounded-xl` to 2, 4, 6 and 10px everywhere, including markup pasted from Tailwind's docs (the compiled `.rounded-lg` is `border-radius: var(--radius)`), so new components never use those four classes. As of October 2026, [shadcn/ui's theming docs](https://ui.shadcn.com/docs/theming) derive the scale by multiplication instead (`--radius-sm: calc(var(--radius) * 0.6)` up to `--radius-4xl`), which has the same project-wide reach. If you theme radius globally, own every component that uses it.

### Three elevation levels

Three levels cover a library: `shadow-raised` for buttons, cards and thumbs, `shadow-floating` for popovers, menus and toasts, `shadow-modal` for dialogs and sheets. Each level carries its own 1px hairline, so an elevated surface needs no border:

```css
:root {
  --elevation-floating: 0 0 0 1px rgb(0 0 0 / 0.06), 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 16px 36px -10px rgb(0 0 0 / 0.16);
}
.dark {
  /* on a black page a drop shadow barely shows: a lit top edge and a hairline do the work */
  --elevation-floating: inset 0 1px 0 0 rgb(255 255 255 / 0.07), 0 0 0 1px rgb(255 255 255 / 0.09), 0 8px 24px -4px rgb(0 0 0 / 0.6), 0 20px 48px -12px rgb(0 0 0 / 0.7);
}
@theme inline {
  --shadow-floating: var(--elevation-floating);
}
```

An interactive Card moves between two of these levels on hover, and the lift runs as a CSS transition on a spring curve (below), so a grid of fifty cards animates without JavaScript.

> Live demo (Card): Hover the card to see it lift from shadow-raised to shadow-floating, then press it to see the dip to 98% (phones get only the press). Switch the site theme to see the dark elevation draw a lit top edge instead. Try it at [Card](https://wingo-ui.com/components/card) and install it with `npx wingo-ui@latest add card`.

### Stacking layers

| Class | Value | Use |
| --- | --- | --- |
| `z-dropdown` | 1000 | in-flow panels that are not portaled |
| `z-sticky` | 1100 | sticky headers, tab bars, CTA bars |
| `z-overlay` | 1200 | the dimmed backdrop |
| `z-modal` | 1300 | dialogs, sheets, drawers |
| `z-popover` | 1400 | portaled menus, selects, popovers |
| `z-toast` | 1500 | toasts |
| `z-tooltip` | 1600 | tooltips, always on top |

Popovers sit above modals on purpose: a select inside a dialog has to open over it. No overlay in the library uses a raw number like `z-50`; small values such as `z-10` only order parts inside one component.

### Motion

CSS transitions use three durations (`duration-fast` 150ms, `duration-normal` 240ms, `duration-slow` 400ms) and named curves (`ease-smooth` is `cubic-bezier(0.22, 1, 0.36, 1)`, `ease-sheet` is `cubic-bezier(0.32, 0.72, 0, 1)`). Our JavaScript springs are also sampled into CSS `linear()` easings: `ease-spring` with `duration-spring` (500ms) matches a spring with stiffness 300 and damping 30.

## Can component props work like design tokens?

Yes, for the decisions that are props: the radius of every button, the default size of fields, the variant of every card. CSS variables cannot set a prop, so Wingo UI reads those from a React provider, and `UIProvider defaults` works as a token layer for props. The free [UIProvider](https://wingo-ui.com/components/ui-config) merges app-wide defaults under the props each component receives, keyed by its `data-slot` name:

```tsx
// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
import { UIProvider } from "@/lib/ui-config";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
      <UIProvider locale="en-US" defaults={{ button: { radius: "full" }, card: { variant: "outline" } }}>
        {children}
      </UIProvider>
    </ThemeProvider>
  );
}
```

Inside a component, the first line reads the defaults, and the signature defaults stay the last fallback:

```tsx
export function Button(buttonProps: ButtonProps) {
  const { variant = "solid", tone = "neutral", size = "md", radius = "md", ...rest } = useDefaults("button", buttonProps);
  // ...
}
```

Explicit props win, then the provider, then the component's own defaults. An `undefined` prop does not override a provider value, so wrappers can pass props through without erasing the theme. The same signature takes `ref` as a regular prop, with no `forwardRef` wrapper; our [React 19 forwardRef migration guide](https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop) moves older components to that pattern, TypeScript types included.

> Live demo (UIProvider): The first button uses its built-in defaults; the two below get radius full, size lg and tone brand from a UIProvider, and the outline one keeps the variant it passes itself. Try it at [UIProvider](https://wingo-ui.com/components/ui-config) and install it with `npx wingo-ui@latest add ui-config`.

## How do you add these tokens to an existing Next.js project?

Run `npx wingo-ui@latest init` in a project that has Tailwind CSS v4. It writes `wingo-ui.json` and `lib/utils.ts` (the `cn` helper), installs `clsx` and `tailwind-merge`, and merges the tokens into the stylesheet that contains `@import "tailwindcss"`. It only adds missing names and keeps your values; `add` later brings any tokens a new component needs. The full block, 74 variables in `:root` plus their dark values and Tailwind mappings, is served at `/r/tokens.css`, and coding agents get the same CSS from the MCP tool `get_theme_tokens` (see our [component library MCP guide](https://wingo-ui.com/blog/component-library-mcp-guide)). The [theming docs](https://wingo-ui.com/docs/theming) show the main tokens with their light and dark values, and the two dark mode setups the CLI supports: a `.dark` class, or `prefers-color-scheme` when your stylesheet already uses it.

To restyle, open the [theme generator](https://wingo-ui.com/theme), pick a brand color, neutrals, radius, font and component defaults, check its contrast report, and apply the export:

```bash
npx wingo-ui@latest theme apply ./theme.json
```

It replaces the values of the tokens the theme sets, in light and dark, adds Tailwind mappings for new ones and prints the `UIProvider` defaults and locale to paste into your provider.

Coming from shadcn/ui: the shared names line up, but two keep different meanings. Our `--card` is a flat gray tile where shadcn's is the card background, and our `--accent` is an orange tint. Because `init` keeps your existing values, a shadcn project keeps its own `--card` and `--accent`, so check the soft Card variant after the merge. Our [shadcn alternatives comparison](https://wingo-ui.com/blog/shadcn-alternatives) covers the wider differences between the two libraries.

## Which mistakes break a Tailwind v4 design system?

The same few mistakes show up in most token files:

- **`dark:` classes in components.** A component that needs `dark:bg-zinc-900` is missing a token.
- **Palette names in components.** `bg-blue-600` cannot follow a theme; use a role or a tone.
- **Fill colors as text.** `text-destructive` on a dark surface fails AA; use the soft foreground.
- **Per-theme values in plain `@theme`.** Keep them in `:root` and `.dark`, mapped with `inline`.
- **A global radius that rewrites Tailwind's scale.** Every pasted `rounded-lg` changes with it.
- **Raw z-index numbers.** Two `z-50` overlays from two authors will collide.
- **No contrast check.** Tokens drift with every tweak; a check in CI catches it the same day.

## Where should you start?

Run `npx wingo-ui@latest init` in your Next.js project, then `npx wingo-ui@latest add button`, which brings the Tones and UIProvider items with it. Replace one hard-coded color in a real screen with a role token, open it in both themes, and add the contrast script to CI. Two more guides in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind) cover the component code around the tokens: the [React 19 forwardRef migration](https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop) and an [SSR-safe React use mobile hook](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) that hydrates without a mismatch.

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [Tones](https://wingo-ui.com/components/tones) (Free): One way to color a component: a tone or any CSS color becomes five CSS variables every variant paints with. Install: `npx wingo-ui@latest add tones`
- [UIProvider](https://wingo-ui.com/components/ui-config) (Free): App-wide configuration for Wingo UI: default props for every component, the Intl locale and the reduced-motion policy. Install: `npx wingo-ui@latest add ui-config`
- [Card](https://wingo-ui.com/components/card) (Free): The surface listings, stats, settings and plans sit on: media, header, body and footer parts, four variants and a lift-and-press mode. Install: `npx wingo-ui@latest add card`
- [Badge](https://wingo-ui.com/components/badge) (Free): Status chips, removable filters and animated counts in three variants and six tones, plus an anchor that pins a count or dot to any corner. Install: `npx wingo-ui@latest add badge`

## FAQ

### Where do design tokens go in Tailwind v4?

In the global CSS file, after @import "tailwindcss". Variables in an @theme block become utilities (--color-surface gives bg-surface), and values that change per theme live in :root and .dark and are mapped with @theme inline. A tailwind.config.js file is no longer required.

### What is the difference between @theme and @theme inline in Tailwind v4?

With @theme, the utility references the theme variable, so bg-surface compiles to var(--color-surface), which is resolved once on :root. With @theme inline, the utility gets the referenced value written in, so bg-surface compiles to var(--surface) and resolves on the element that uses it. Use inline whenever a theme variable points at another variable.

### Should Tailwind v4 colors be OKLCH or hex?

Either works, because tokens are plain CSS colors and the format does not change contrast. Tailwind's own palette moved to oklch in v4.0. OKLCH helps most when you derive colors in code, since you can keep hue and chroma and move lightness until a contrast target passes.

### How do I add dark mode to Tailwind v4 design tokens?

Redefine the same variables in a .dark block and add @custom-variant dark (&:is(.dark *)); so dark: utilities follow the class too. next-themes with attribute="class" sets the class before the first paint. Components that use semantic tokens then need no dark: classes.

### How do I check color contrast for design tokens?

List every text token with each surface it can sit on, convert the values to sRGB, and compute the WCAG ratio for light and dark. Fail the build below 4.5:1 for body text; focus rings and other UI shapes need 3:1.

### Are the Wingo UI design tokens free?

Yes. npx wingo-ui@latest init merges the tokens into your stylesheet without an account, and the Button, Card, Tones and UIProvider items in this guide are free. As of release 1.7.0, 75 of the 326 registry items are free and the rest need Pro.

---

Source: https://wingo-ui.com/blog/tailwind-v4-design-tokens
