Design tokens are named design decisions, such as a color, a radius or a type size, stored once and used everywhere. Tailwind CSS v4 made CSS custom properties the place they live: a variable defined in an @theme block becomes both a CSS variable and a set of utility classes. On 28 October 2025, the W3C Design Tokens Community Group published the first stable version of its file format (2025.10), so there is now a vendor-neutral way to store those decisions outside the stylesheet too.
This post builds a small pipeline from one to the other: a token file in the DTCG format, a script that turns it into Tailwind v4 CSS, and colors in OKLCH with measured contrast. It also covers two bugs that are easy to hit when moving tokens into v4. The examples come from this site's own stylesheet, which is written by hand in one CSS file; the script produces the same structure from a token file, which is what you want once a design tool or a second codebase needs the same values.
Three layers, and where Tailwind fits
Token systems usually have three layers:
- Primitives are the raw values: a palette of greys, a brand amber, a spacing scale. They have no opinion about where they're used.
- Semantic tokens give primitives a job:
background,foreground,brand,muted-foreground. Components only ever use these. - Component tokens are decisions that belong to one pattern, such as the corner radius of a bento tile.
In Tailwind v4 the layers map onto CSS like this. Semantic tokens are plain variables on :root, redefined for dark mode. An @theme inline block turns them into color utilities. Values that don't change between themes, like type sizes and radii, go straight into @theme. Here is a trimmed version of this site's setup:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: oklch(0.985 0.004 70);
--foreground: oklch(0.16 0.006 70);
--brand: oklch(0.53 0.12 66);
}
.dark {
--background: oklch(0.165 0.006 65);
--foreground: oklch(0.95 0.006 75);
--brand: oklch(0.76 0.13 72);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand: var(--brand);
}
@theme {
--text-caption: 0.8125rem;
--text-caption--line-height: 1.5;
--radius-tile: 1.25rem;
}Every utility (bg-background, text-brand, text-caption, rounded-tile) stays the same in both themes. Switching to dark mode only swaps the values behind the semantic names, which is why components never need a dark: variant for color.
Writing tokens in the DTCG format
The DTCG Format Module 2025.10 describes tokens as JSON. A few rules cover most of what you need:
- A token is an object with a
$value. Everything the format defines starts with a dollar sign:$type,$description,$extensions,$deprecated. - A token without its own
$typeinherits it from the closest parent group that has one, so you can set"$type": "color"once on a palette group. - An alias is a string in curly braces, such as
"{palette.amber.600}", and always resolves to the referenced token's$value. - In the 2025.10 Color Module, a color value is an object with a
colorSpace, a list ofcomponents, an optionalalphaand an optionalhexfallback. OKLCH is one of the supported color spaces. - A dimension is an object with a numeric
valueand aunit, which may only bepxorrem.
The recommended file extensions are .tokens and .tokens.json. Here is a small token file with a palette, two themes and one radius:
{
"palette": {
"$type": "color",
"paper": { "$value": { "colorSpace": "oklch", "components": [0.985, 0.004, 70] } },
"ink": { "$value": { "colorSpace": "oklch", "components": [0.16, 0.006, 70] } },
"night": { "$value": { "colorSpace": "oklch", "components": [0.165, 0.006, 65] } },
"bone": { "$value": { "colorSpace": "oklch", "components": [0.95, 0.006, 75] } },
"amber": {
"600": { "$value": { "colorSpace": "oklch", "components": [0.53, 0.12, 66] } },
"300": { "$value": { "colorSpace": "oklch", "components": [0.76, 0.13, 72] } }
}
},
"light": {
"$type": "color",
"background": { "$value": "{palette.paper}" },
"foreground": { "$value": "{palette.ink}" },
"brand": { "$value": "{palette.amber.600}", "$description": "Links and accents. L 0.53 keeps text above 4.5:1." }
},
"dark": {
"$type": "color",
"background": { "$value": "{palette.night}" },
"foreground": { "$value": "{palette.bone}" },
"brand": { "$value": "{palette.amber.300}" }
},
"radius": {
"$type": "dimension",
"tile": { "$value": { "value": 1.25, "unit": "rem" } }
}
}The format module itself doesn't define light and dark themes. Keeping one group per theme, as above, is the script's own convention.
From JSON to @theme in sixty lines of Node
The script flattens the groups, resolves aliases, and writes the CSS shown earlier. It has no dependencies and fails loudly on an alias that points nowhere or loops back on itself.
// Usage: node scripts/build-tokens.mjs tokens.tokens.json > app/tokens.css
import { readFileSync } from "node:fs";
const tree = JSON.parse(readFileSync(process.argv[2], "utf8"));
// Flatten groups into { "palette.amber.600": { type, value } }. A token
// without $type inherits it from the closest group that has one.
const tokens = {};
(function walk(node, path, inherited) {
const type = node.$type ?? inherited;
if ("$value" in node) return void (tokens[path.join(".")] = { type, value: node.$value });
for (const [key, child] of Object.entries(node)) {
if (!key.startsWith("$")) walk(child, [...path, key], type);
}
})(tree, [], undefined);
const cssName = (path) => `--${path.replace(/^(light|dark)\./, "").replace(/\./g, "-")}`;
const ALIAS = /^\{([^}]+)\}$/;
function css(path, seen = new Set()) {
const { type, value } = tokens[path];
const alias = typeof value === "string" && value.match(ALIAS);
if (alias) {
if (!tokens[alias[1]]) throw new Error(`${path} points at a missing token: ${alias[1]}`);
if (seen.has(alias[1])) throw new Error(`Circular alias at ${path}`);
css(alias[1], new Set([...seen, path])); // validates the chain
return `var(${cssName(alias[1])})`; // keep the link visible in CSS
}
if (type === "color" && value.colorSpace === "oklch") {
const [l, c, h] = value.components;
return value.alpha != null && value.alpha < 1 ? `oklch(${l} ${c} ${h} / ${value.alpha})` : `oklch(${l} ${c} ${h})`;
}
if (type === "color" && value.hex) return value.hex;
if (type === "dimension") return `${value.value}${value.unit}`;
throw new Error(`${path}: unsupported ${type} value`);
}
const group = (prefix) => Object.keys(tokens).filter((path) => path.startsWith(`${prefix}.`));
const block = (selector, paths) => `${selector} {\n${paths.map((p) => ` ${cssName(p)}: ${css(p)};`).join("\n")}\n}`;
const semantic = group("light").map((path) => path.slice("light.".length));
console.log(
[
"/* Generated from tokens.tokens.json. Edit the tokens, not this file. */",
block(":root", [...group("palette"), ...group("light")]),
block(".dark", group("dark")),
// Utilities read the semantic variables directly (see @theme inline).
`@theme inline {\n${semantic.map((name) => ` --color-${name}: var(--${name});`).join("\n")}\n}`,
`@theme {\n${group("radius").map((p) => ` ${cssName(p)}: ${css(p)};`).join("\n")}\n}`,
].join("\n\n")
);Run against the file above, it prints:
/* Generated from tokens.tokens.json. Edit the tokens, not this file. */
:root {
--palette-paper: oklch(0.985 0.004 70);
--palette-ink: oklch(0.16 0.006 70);
--palette-night: oklch(0.165 0.006 65);
--palette-bone: oklch(0.95 0.006 75);
--palette-amber-300: oklch(0.76 0.13 72);
--palette-amber-600: oklch(0.53 0.12 66);
--background: var(--palette-paper);
--foreground: var(--palette-ink);
--brand: var(--palette-amber-600);
}
.dark {
--background: var(--palette-night);
--foreground: var(--palette-bone);
--brand: var(--palette-amber-300);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand: var(--brand);
}
@theme {
--radius-tile: 1.25rem;
}Aliases become var() references instead of copied values, so the browser's developer tools show which primitive a semantic token points at, and changing the palette changes everything that uses it. Semantic variables have plain names on :root, and only the @theme copies carry Tailwind's --color- namespace; giving both the same name would make the theme variable refer to itself, which CSS treats as invalid. And the radius goes into a plain @theme block, because it is the same in both themes and doesn't reference anything.
@theme or @theme inline?
Tailwind's documentation says to use the inline option when theme variables reference other variables. Without it, a utility like font-sans compiles to var(--font-sans), and CSS resolves that variable where it is defined, on :root. If the variable it points at is only set further down the tree, it has no value at that point, and the browser falls back. With inline, the utility uses the referenced variable directly and resolves it on the element being styled.
Here is the same bug, live, using this site's monospace font:
Same markup, same variables. On the left, --sans was defined on the outer box, where --face doesn't exist yet, so the browser substituted the fallback right there. On the right, the inner box reads --face directly, which is what @theme inline makes the generated utility do.
It matters in exactly the situations a token system tends to create: fonts loaded with next/font, which sets its variable through a class, theme classes applied to part of a page rather than the root, and any semantic token that is overridden on a subtree. Using @theme inline for every theme variable that references another variable avoids the whole class of problem.
Why color tokens belong in OKLCH
MDN describes the L in oklch() as perceived lightness, which is different from the L in hsl(). The difference sounds academic until you try to keep text readable while changing hue.
Read this in OKLCH
Read this in HSL
Both rows keep their lightness fixed while the hue turns. In OKLCH the contrast with the page stays between 4.6 and 5.4 to 1 all the way round. In HSL the same lightness number gives 1.8 to 1 for yellow and 9.6 to 1 for blue.
Across the full hue circle, the OKLCH color stays between 4.59 and 5.40 to 1 against the page, above the 4.5:1 that WCAG 2.2 asks of body text all the way round. The HSL color with the same lightness number ranges from 1.80 to 1 (unreadable yellow) to 9.61 to 1 (deep blue). Those figures come from the same conversion this page uses, and a unit test in this site's repository pins them so they can't drift.
For a token system, this means lightness can be chosen per role and hue per brand. A semantic brand token at lightness 0.53 stays readable whether the brand is amber, teal or violet, which makes multi-brand and seasonal themes much safer to build. Tailwind made the same move for its own palette: v4, released on 22 January 2025, upgraded the default colors from rgb to oklch. Browser support is settled too: MDN lists oklch() as available across browsers since May 2023.
To build a whole 50 to 950 scale from one hue, with the chroma fitted to the gamut and the contrast of every step checked, see the guide to an OKLCH color palette for Tailwind v4.
One caveat is gamut. High chroma can push a color outside what an sRGB screen can show, and the browser maps it back in. This site's own brand token, oklch(0.53 0.12 66), lands a hair outside sRGB (its blue channel works out to -0.0017 before clipping), which makes no visible difference. At the same lightness and chroma, teal hues land further out. Keep chroma moderate for text colors, or check the gamut when you pick them.
Dark mode is a second set of semantic values
Dark mode in this setup is a .dark block that redefines semantic tokens and nothing else. Primitives and component tokens stay put, and every utility keeps working. Two habits help.
Keep the hue between themes. This site's dark theme uses the same warm hues (65 to 75) as the light one; an earlier version switched to a cool blue-grey at hue 255, and the two themes read as different brands.
Raise the lightness of accent colors in dark mode instead of reusing the light values. The brand token goes from lightness 0.53 on paper to 0.76 on the dark background, which keeps brand-colored text readable on both.
You can check the result without leaving this page. The table below reads the :root and .dark blocks from the live stylesheet and measures each pair the site actually uses:
Reading this page's tokens…
One thing you won't find if you open this site's shipped CSS is the oklch() values. The production build rewrites them as lab() colors, each with a hex fallback in an earlier rule, which is why the audit asks the browser to resolve every value instead of parsing the source syntax. The tokens are still written in OKLCH, and the color on screen is the same.
The tailwind-merge trap with custom type tokens
Many projects combine classes with a cn() helper built on tailwind-merge, which removes conflicting utilities. tailwind-merge doesn't read your CSS. When it sees a text- utility with a value it doesn't recognize, it assumes a color. So a custom size token like text-caption gets treated as a color, and the first real color that follows replaces it:
import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge, twMerge } from "tailwind-merge";
twMerge("text-caption text-muted-foreground"); // "text-muted-foreground": the size is gone
// Tell tailwind-merge which text-* values are sizes (the --text-* tokens in @theme).
const merge = extendTailwindMerge({
extend: {
theme: {
text: ["hero", "section", "display", "title", "heading", "lead", "body", "caption", "meta", "eyebrow"],
},
},
});
merge("text-caption text-muted-foreground"); // "text-caption text-muted-foreground"
export function cn(...inputs: ClassValue[]) {
return merge(clsx(inputs));
}The bug is silent: nothing errors, the text just comes out at the inherited size. If your theme adds sizes, radii, shadows or other values under Tailwind's namespaces, add them to tailwind-merge's configuration in the same commit.
Checking tokens where they're used
Tokens are only useful if you can see them on the page. Because the generated CSS keeps aliases as var() references, the computed styles panel in the browser shows the chain from utility to semantic token to primitive. On this site there is also a shortcut: press I to turn on Inspect Mode, then hover or Tab to any element to see the tokens it resolves to, read back from the same CSS variables the page is styled with.
The same token rules carry into layout: the bento grid CSS and Tailwind guide uses a shared gap, radius and padding for every tile, and the bento grid generator exports those values in its CSS.
