Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast
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 (opens in a new tab)), and the theme variables docs (opens in a new tab) list the namespaces. We run Tailwind 4.3.3 as of October 2026.
The namespaces a component library uses most:
--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:
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:
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 (opens in a new tab) 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:
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
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
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 (opens in a new tab) 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:
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 (opens in a new tab) 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:
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:
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:
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 (opens in a new tab) 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]".
$ npx wingo-ui@latest add tonesThe free Button is the reference consumer. Its variant map contains no color at all:
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 (opens in a new tab) 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 (opens in a new tab), 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:
What a designed dark theme looks like in practice, from our token file:
- Surfaces lighten as they come forward.
#000page,#121212card,#1A1A1Asurface,#262626popover. - Fills stay, soft surfaces flip. The brand fill is
#D63F00in both themes, so white text keeps 4.59:1. Its soft surface goes from#FFEFE6to#2A1509, and its text from#B03400to#FF8A4C. - The neutral fill inverts.
primaryis black with white text in light mode and#FAFAFAwith#171717in 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.
--ringisoklch(0.60 0.20 36.93)in light andoklch(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-cardin light butbg-foreground/7in dark, because the dark--cardis 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 (opens in a new tab)), and 3:1 for UI components and focus indicators against what is next to them (1.4.11 (opens in a new tab)).
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:
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 (opens in a new tab)), and its colors reference (opens in a new tab) 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 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:
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:
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.
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 (opens in a new tab) 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:
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.
$ npx wingo-ui@latest add cardStacking layers
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 merges app-wide defaults under the props each component receives, keyed by its data-slot name:
Inside a component, the first line reads the defaults, and the signature defaults stay the last fallback:
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 moves older components to that pattern, TypeScript types included.
$ npx wingo-ui@latest add ui-configHow 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). The theming docs 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, pick a brand color, neutrals, radius, font and component defaults, check its contrast report, and apply the export:
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 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 needsdark:bg-zinc-900is missing a token.- Palette names in components.
bg-blue-600cannot follow a theme; use a role or a tone. - Fill colors as text.
text-destructiveon a dark surface fails AA; use the soft foreground. - Per-theme values in plain
@theme. Keep them in:rootand.dark, mapped withinline. - A global radius that rewrites Tailwind's scale. Every pasted
rounded-lgchanges with it. - Raw z-index numbers. Two
z-50overlays 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 cover the component code around the tokens: the React 19 forwardRef migration and an SSR-safe React use mobile hook that hydrates without a mismatch.
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.
- Tailwind CSS
- Design tokens
- Dark mode
- OKLCH
- Design systems
- Accessibility