Skip to content
Chapter 19Lesson 4

Custom properties and design tokens

CSS custom properties and the primitive, semantic, and component token tiers behind your Tailwind theme.

A chapter of theming with Tailwind left three questions open.

Can JavaScript change a token while the page is live, and does React need to know? When you added .dark to <html> last chapter, the whole app repainted at once, so how does one class on one element reach every color on the page? And could a customer set their own brand color in place of the brand-blue you defined in @theme, the way every real web app lets them?

All three have the same answer, and it has been in front of you the whole time: what a var(--color-primary) reference actually points at. By the end of this lesson, the var() you have written on faith becomes something you can read, write, scope to one part of the page, and architect into a system that survives a rebrand.

This lesson builds that machinery. What makes a good color value, and how animation interpolates between values, come later.

A custom property is a binding, not a constant

Section titled “A custom property is a binding, not a constant”

Any CSS property whose name starts with -- is a custom property , also called a “CSS variable.” You declare it on a selector and read it back anywhere with var(--name).

The obvious mental model is that this is a constant, a named value you reuse. That model is wrong, and replacing it is the point of this section.

Start with the underwhelming version: declare a custom property on a parent, read it on a child.

.toolbar {
--gap: 1rem;
}
.toolbar button {
gap: var(--gap);
}

So far this is just a constant: you named a value and reused it. Two things make custom properties more than that.

First, they inherit. Custom properties are members of the inheriting family you already met: set one on an ancestor and every descendant sees it. Declare one on <html> and var() reads it on every element on the page, no re-declaration needed.

Second, they are live. Change the declared value, at any point and by any means, and every var() that reads it recomputes, with no reload or rebuild. A constant resolves once and freezes; a custom property resolves at use time, against the computed value on that element. Change what sits underneath, and every reader follows.

That is the reframe the whole lesson hangs on: a custom property is a binding, not a constant. --color-foreground is not “the value oklch(...)”; it is a live reference the browser re-resolves at every use site, every time the value beneath it changes.

You have to watch it move. Drag the slider below.

--accent is declared once, on the panel. Each element inside reads it with var() in a different property; drag the hue and they all re-resolve together.

You declared --accent once, yet a background, a heading color, a button, and a border all move together, because each is a var(--accent) read that re-resolves every frame. You are not setting four values; you are moving one binding and watching everything wired to it follow.

Here is the payoff owed since last chapter: every token you wrote in @theme is one of these. When you defined --color-brand, Tailwind put it in :root as a plain custom property, and every utility that consumes it (bg-card, text-primary, border-border) compiles to a var() read. The --{namespace}-{name} rule, where --color-primary mints bg-primary, text-primary, and border-primary, was minting bindings all along. You have been writing custom properties for a chapter without the word for them.

The pattern is to declare a token’s default on :root, then override it on a descendant selector. Watch what that gives you.

:root {
--color-primary: oklch(0.55 0.2 264);
}

The baseline. This governs the whole document: every bg-primary resolves to this value unless something nearer overrides it.

One token, re-declared on three selectors. The override flows down by inheritance, and every var(--color-primary) below the selector re-resolves to the new value. The dark tab is not new: it is the dark-mode swap from last chapter, finally explained. You re-declared the semantic tokens under .dark, and because custom properties inherit and stay live, the whole subtree repainted.

The shape reaches past dark mode. A marketing hero that wants a punchier accent uses .marketing-hero { --color-primary: ... }; a multi-tenant app gives each customer their brand with [data-org="acme"] { --color-primary: ... }. Same mechanic, different selector: declare the token nearer the element, and the nearer declaration wins for that subtree. You will build the organization layer that drives [data-org] much later, so treat it as a use case you understand, not one to wire up today.

One declaration re-themes an entire subtree, and the components inside change nothing: they keep rendering bg-primary, unaware that the binding under them now points somewhere new. That works because of two facts you have assembled. Utilities reference var(--token), the plain-@theme link from the last section, and custom properties inherit, the reach from the section before. Overriding down the tree is those two facts paying off together.

Reading and writing tokens from JavaScript

Section titled “Reading and writing tokens from JavaScript”

CSS can override a token. JavaScript can change one while the page is live, and that is a different channel: it answers the “does React need to know?” question with a firm no.

The DOM gives you the imperative twin of the CSS you just wrote, in three calls.

element.style.setProperty('--brand', value);
getComputedStyle(element).getPropertyValue('--brand');
element.style.removeProperty('--brand');

setProperty declares a custom property on the element you call it on, scoped to that element’s subtree just as a CSS selector is scoped to the elements it matches. Call it on document.documentElement and you have changed :root and repainted the whole page; call it on a deeper node and you have scoped the change to that branch. getPropertyValue reads it back, and removeProperty deletes your override so the inherited value shows through again.

What matters more than the syntax: these writes update pixels, not React. A setProperty call repaints through the cascade with no re-render, so React never finds out it happened. That is both the feature and the trap.

The feature: a color-picker can drag smoothly, writing the property on every pointer move, without thrashing the React tree sixty times a second. The trap: the custom property is a one-way visual output, not a place React reads from, so any component that needs the value to make a decision must keep it in state too. Discover setProperty, write a value, and you will wonder why the component that “uses” the color never re-rendered. You told the browser to repaint, not React to update.

The senior shape threads both channels: a theme color-picker writes the property live for instant feedback, then commits the chosen value to React state (and persistence) when the user releases. The visual channel carries the drag, the state channel carries the truth.

const [brand, setBrand] = useState('#6366f1');
const paint = (event: React.FormEvent<HTMLInputElement>) => {
document.documentElement.style.setProperty('--color-brand', event.currentTarget.value);
};
const commit = (event: React.ChangeEvent<HTMLInputElement>) => {
setBrand(event.currentTarget.value);
};
return <input type="color" value={brand} onInput={paint} onChange={commit} aria-label="Brand color" />;

The committed brand color lives in React state. This is the value a component can read to make a decision; the binding alone is invisible to React.

const [brand, setBrand] = useState('#6366f1');
const paint = (event: React.FormEvent<HTMLInputElement>) => {
document.documentElement.style.setProperty('--color-brand', event.currentTarget.value);
};
const commit = (event: React.ChangeEvent<HTMLInputElement>) => {
setBrand(event.currentTarget.value);
};
return <input type="color" value={brand} onInput={paint} onChange={commit} aria-label="Brand color" />;

onInput fires on every drag. We write the custom property on :root, the page repaints instantly through the cascade (every bg-brand follows), and React never knows. This is the visual channel.

const [brand, setBrand] = useState('#6366f1');
const paint = (event: React.FormEvent<HTMLInputElement>) => {
document.documentElement.style.setProperty('--color-brand', event.currentTarget.value);
};
const commit = (event: React.ChangeEvent<HTMLInputElement>) => {
setBrand(event.currentTarget.value);
};
return <input type="color" value={brand} onInput={paint} onChange={commit} aria-label="Brand color" />;

onChange fires once, on release. Only now do we commit the chosen color to state, so the component and persistence learn the new truth. This is the state channel.

const [brand, setBrand] = useState('#6366f1');
const paint = (event: React.FormEvent<HTMLInputElement>) => {
document.documentElement.style.setProperty('--color-brand', event.currentTarget.value);
};
const commit = (event: React.ChangeEvent<HTMLInputElement>) => {
setBrand(event.currentTarget.value);
};
return <input type="color" value={brand} onInput={paint} onChange={commit} aria-label="Brand color" />;

Two channels on one input: paint every frame for instant feedback, record once on commit. Sixty smooth repaints, one re-render.

1 / 1

The read side has one subtlety that trips up most people. You set --brand with a space after the colon, so you expect that space glued to the front of the value when you read it back, and you reach for .trim() on reflex. Predict what actually happens before you run it.

The value is set with two deliberate leading spaces. Predict the three logged lines — the raw read (printed via JSON.stringify so any whitespace would be visible), its length, and the strict comparison against the unspaced form. Predict what this program prints, then press Check.

const el = document.documentElement;
el.style.setProperty('--brand', ' #2563eb');
const raw = getComputedStyle(el).getPropertyValue('--brand');
console.log(JSON.stringify(raw));
console.log(raw.length);
console.log(raw === '#2563eb');

Two smaller details. Custom-property names are case-sensitive, so --Brand and --brand are different properties and a casing mismatch fails with no error to explain why. And var() takes an optional fallback, var(--x, 1rem), used when --x isn’t set; it exists for consuming tokens you didn’t define, so in a project that owns its tokens you reach for it almost never.

One more case is the same problem you already cured. A multi-tenant brand color usually comes from server data, and if JavaScript writes it after React hydrates, the page paints once in the default theme and then snaps to the tenant’s color: a flash of the wrong styling, the same FOUC the next-themes setup avoided last chapter. The cure is identical, a tiny inline <script> in <head> that calls setProperty before the first paint, so recognize it rather than relearn it.

The JS API is the imperative path; inside a component you want the declarative one. Write a custom property in the style prop like any other style, with the -- name as the key.

<div style={{ '--card-padding': '1.5rem' }} className="p-[var(--card-padding)]">
{children}
</div>

The inline style declares the custom property on this element (the React equivalent of setProperty), and p-[var(--card-padding)] reads it. The value can differ on every instance, which static utilities cannot do: p-6 is fixed forever, but --card-padding can be 1.5rem here and 3rem there, driven by a prop.

That bracket form (p-[var(--name)], bg-[var(--name)], w-[var(--name)]) is Tailwind’s arbitrary-value escape hatch: it reads a custom property that isn’t on a @theme namespace, including any runtime variable you set with inline style. Reach for it only for genuine one-offs, such as a width or transform driven by a runtime value; everywhere else a semantic token already covers you. The semantic token is the rule, the arbitrary var() the exception.

This resolves an apparent contradiction with the cascade lesson, where reaching for inline style to win a static conflict was the wrong move because it sidesteps the layer system. A per-instance value is the opposite case: no static class can carry a value computed at render time, so inline style is the only tool for it. The deciding question is whether the value is known ahead of time or computed per instance.

Now wire it yourself, making two cards of the same component render with two different accents.

The Card component takes an accent prop but ignores it, so both cards render with no colored edge. Project that prop onto a --accent custom property with inline style on the card's root, then make the thick left border read it with border-l-4 border-[var(--accent)]. One definition, two instances — the borders should end up different colors, matching the target.

Target
Your output LIVE

When the two borders diverge you have crossed the bridge from “props go into JSX” to “props go into the design system”: one definition, a token value that changes per instance, driven by a prop through a custom property.

Designing the token system: primitive, semantic, component

Section titled “Designing the token system: primitive, semantic, component”

You can read a custom property, write it from CSS, JavaScript, and React, and scope it to any subtree. The remaining question is architectural: how do you organize hundreds of them so a design system stays changeable as the product grows?

The industry converged on three tiers. The named design decisions you are organizing are called design tokens , and each tier exists to solve a problem the one before it can’t.

Tier one, primitives: the raw palette. --gray-50 through --gray-950, --blue-500, --spacing-4. These are values, not meanings: literal colors and spacings, named for what they look like. This is Tailwind’s default palette, free the moment you import Tailwind. A primitive knows it is blue but has no idea what blue is for.

Tier two, semantic tokens: the component contract. --color-foreground, --color-primary, --color-destructive, --color-muted. A semantic token names a role and points at a primitive. --color-primary doesn’t say “blue”; it says “the primary action color,” and today it resolves to --blue-600. This is the tier components read, and the token set shadcn ships, which you’ll meet in full when you bring shadcn into the project. It is also why the dark swap worked: .dark touches no component; it re-points --color-primary from --blue-600 to --blue-400, and every bg-primary follows the binding.

Tier three, component tokens: the rare exception. --button-primary-bg, --card-padding. A token scoped to a single component, for the genuine cases the semantic tier can’t express. Reach for it rarely: most components live their whole lives on the semantic tier. A component token should read as a deliberate exception, not a default.

The resolution chain is the core of all this, so watch it run.

Component reads the semantic tier
<button className=" bg-primary ">
Semantic token points at a primitive
--color-primary : var(--blue-600)
Primitive holds the value
--blue-600 : oklch(0.55 0.2 264)
What paints
bg-primary
Same component, untouched
<button className=" bg-primary ">
Dark re-points the same token
.dark { --color-primary : var(--blue-400) }
Same utility, different color
bg-primary
One utility, three hops. bg-primary reads the semantic token, which points at a primitive, which holds the value. Dark mode re-points the middle hop and the same utility lands on a different color — the component is untouched.

bg-primary reads the semantic token, which points at the primitive, which holds the value: three hops of indirection, and that indirection is the entire payoff. In the dark strand the bg-primary and the component are unchanged, but the middle pointer now aims at a lighter primitive, so the button lands on a different color while the component never knows. That is the property you are buying.

The chain makes the central rule clear: components reference the semantic tier, never primitives. So bg-primary, never bg-blue-600; direct primitive use in a component is a code smell. A rebrand then means re-pointing the semantic-to-primitive binding in one place, and the whole app re-themes. Hard-code bg-blue-600 instead, and a rebrand becomes a find-and-replace that is never quite complete, because one bg-blue-600 is always hiding in a component nobody opened this quarter, still blue after everything else turned purple. The indirection makes the change one line instead of a thousand.

In CSS, the chain is literally one token reading another:

:root {
/* Primitive — a value */
--blue-600: oklch(0.55 0.2 264);
/* Semantic — points at the primitive (this is the indirection) */
--color-primary: var(--blue-600);
/* Component — points at the semantic; rare, shown for completeness */
--button-primary-bg: var(--color-primary);
}

--color-primary: var(--blue-600) is the indirection made literal: a custom property whose value is another custom property. The semantic token doesn’t hold a color; it points at the primitive that does. Re-point it once, and everything downstream follows.

Now put the distinction to work. Sort each token into its tier:

Sort each token into its tier. Primitive = a raw value (names what it looks like). Semantic = a role components read (names what it's for). Component = scoped to one component. Drag each item into the bucket it belongs to, then press Check.

Primitive A raw value — names appearance
Semantic A role components read
Component Scoped to one component
--blue-500
--gray-900
--spacing-4
--color-primary
--color-foreground
--color-destructive
--radius-md
--button-primary-bg
--card-padding

If you can sort those cleanly, you have the skill this chapter was after: read any var(--token) in the project and name its tier.

Token systems usually break at the naming layer, so treat these three rules as decisions, not preferences.

Pair every surface with its foreground. Define --color-{role} alongside a matching --color-{role}-foreground for the text that sits on it: --color-primary and --color-primary-foreground, --color-destructive and --color-destructive-foreground. The pairing keeps text legible however the surface color changes, so you never land primary text on a primary background by accident.

Express states with opacity, not new tokens. A hover state is bg-primary/80, the same token at 80% opacity, rather than a separate --color-primary-hover. Fewer tokens to define, fewer to keep in sync, same result.

Name every namespace for purpose too. --spacing-{role}, --radius-{role}, and --shadow-{role} are named for what they are for, never for what they are.

The rule under all of it: a token name describes what the token is for, never what it looks like. So --color-destructive, not --color-red. A destructive button is red today, but if next quarter’s rebrand makes destructive actions orange, a token named --color-red now holds an orange value, a contradiction you read past every time. The name is an API, and renaming it is a breaking change that ripples through every component that referenced it. Name it after the durable purpose, not the volatile color, and it survives the rebrand the color never will.

To the browser, a plain custom property is an untyped string. It doesn’t know --gradient-angle: 0deg is an angle; it just knows it is some text. Because it can’t tell what kind of value it holds, it can’t interpolate it, so a transition or animation on a bare custom property snaps from one value to the next instead of easing between them. @property fixes this by telling the browser the type.

You register the property with an at-rule:

@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}

It takes three fields. syntax is the type: <angle>, <color>, <length>, or * for “any.” inherits says whether the value flows down the tree like a normal custom property. initial-value is the starting value, required unless syntax is the universal *. Once registered, the browser knows --gradient-angle is an angle and interpolates it smoothly from 0deg to 360deg instead of jumping.

The use for this is narrow: animatable custom properties, like a gradient angle that spins or a property that drives a transform between values. For a static token, plain @theme and :root are all you need. The instinct to carry away is a debugging one: a token that animates but snaps instead of easing is an unregistered custom property. The keyframe and transition syntax comes later, in the chapter on motion.

The canonical references and a couple of deeper dives for everything in this lesson: