Custom properties and the three-tier token model
CSS custom properties and the primitive, semantic, and component token tiers that turn the design tokens behind your Tailwind theme into a system that survives a rebrand.
You have spent a whole chapter theming with Tailwind, and three questions about it went unanswered.
Can JavaScript change a token while the page is live, and if it can, does React need to know? When you flipped the theme in the last chapter, adding .dark to <html> repainted the whole application at once, so how does a single class on one element reach every color on the page? And the brand-blue you defined when you set up @theme: could a customer set their own brand color, the way every real SaaS lets you?
All three questions have the same answer, and it has been in front of you the whole time. The answer is what a var(--color-primary) reference actually points at. By the end of this lesson, the var() you have been writing on faith becomes something you can read, write, scope to one section of the page, and architect into a system that survives a rebrand.
This lesson covers the machinery, not the color itself. What makes a good color value, and how animation interpolates between values, both come later. Here you build the substrate every token sits on, along with the pattern for organizing tokens once you have more than a handful.
A custom property is a binding, not a constant
Section titled “A custom property is a binding, not a constant”Here is the primitive in one line. Any CSS property whose name starts with -- is a custom property , also called a “CSS variable.” You declare it on a selector, and you read it back anywhere with var(--name).
Build the idea up slowly, because the obvious mental model is wrong. A custom property is not a constant, not just a named value you reuse. Replacing that wrong model with the right one is what this section is for.
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 looks exactly like a constant. You named a value, then reused it. If that were all custom properties did, they would be a mild convenience and this section would be over. Two things make them more than that.
First, they inherit. When you learned what flows down the DOM tree, custom properties were named as members of the inheriting family: set one on an ancestor and every descendant sees it. A custom property declared on <html>, then, is visible to var() in every element on the page, because it inherits all the way down the tree. You don’t have to re-declare it; it flows.
Second, they are live. This is where the constant idea breaks down. When you change the declared value of a custom property, at any point and by any means, every var() that reads it recomputes, with no reload and no rebuild. A constant is resolved once and then frozen. A custom property is resolved at the moment it is used, against the computed value of the property on that element. Change what sits underneath it, and every reader follows.
That reframe is the one 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, a name the browser re-resolves at every use site, every time the value beneath it changes anywhere in its subtree.
Reading about this only gets you so far; you have to watch it move. Drag the slider below.
Project settings
One binding, four readers. Nothing below sets —accent;
it flows down from the panel by inheritance.
You declared --accent in exactly one place, yet a background, a heading color, a button, and a border all move together, because each one is a var(--accent) read that re-resolves on every frame as the value underneath it changes. A constant could never do that. You are not setting four values; you are moving one binding and watching everything wired to it follow.
Here is the payoff you have been owed since the 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 down to a var() read. The --{namespace}-{name} rule you learned, where --color-primary mints bg-primary and text-primary and border-primary, was minting bindings the whole time. You have been writing custom properties for a full chapter; you just didn’t have the word for them yet.
Overriding tokens down the tree
Section titled “Overriding tokens down the tree”You now have the binding. The next move is to change what it points at, and the cleanest way is one you have already shipped.
The pattern is to declare the 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 declaration governs the whole document: every bg-primary below resolves to this value unless something nearer overrides it.
.dark { --color-primary: oklch(0.7 0.18 264);}The dark subtree. Add .dark to <html> and this re-declaration wins inside it, so every bg-primary under .dark re-resolves to the lighter value. This is exactly the swap you shipped last chapter.
[data-org="acme"] { --color-primary: oklch(0.6 0.21 16);}A tenant scope. Wrap a section (or the whole app) in [data-org="acme"] and everything inside re-themes to the customer’s brand, without touching a single component.
There you have 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 a new idea: it is the dark-mode swap you built last chapter, finally explained. You re-declared the semantic tokens under .dark, and because custom properties inherit and stay live, the whole subtree repainted. You had already shipped a subtree override without naming it; this is the general shape it belongs to.
That shape reaches well past dark mode. A marketing hero that wants a punchier accent uses .marketing-hero { --color-primary: ... }. A multi-tenant app where Acme gets their brand and Globex gets theirs uses [data-org="acme"] { --color-primary: ... }. Same mechanic, different selector: declare the token nearer the element, and the nearer declaration wins for that subtree. Per-tenant branding is one of the clearest reasons a real SaaS leans on this. You will build the organization layer that drives [data-org] much later in the course, so for now treat it as a use case you understand, not a feature to wire up today.
This is the reflex an experienced engineer reaches for instead of overriding colors element by element. One declaration re-themes an entire subtree, and the components inside it 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 now assembled. Utilities reference var(--token), the plain-@theme link from the last section, and custom properties inherit, the reach from the section before that. 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, but the first question that opened this lesson was about JavaScript: changing a token while the page is live. That is a different channel, and 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, so its scope is exactly that element’s subtree, the same way a CSS selector’s scope is the elements it matches. Call it on document.documentElement and you have changed :root, which repaints the whole page; call it on a deeper node and you have scoped the change to that branch. Writing the token from JS on an element is the imperative version of declaring that token on a selector in CSS. getPropertyValue reads it back, and removeProperty deletes your override so the inherited value shows through again.
The point that matters more than the syntax is this: 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.
It is the feature because a color-picker can drag smoothly, writing the property on every pointer move, without thrashing the React tree sixty times a second. It is the trap because if a component needs to read that value to make a decision, the value has to live in React state as well. The custom property is a one-way visual output, not a place React reads from. A common first mistake is to discover setProperty, write a value with it, and then be confused that the component which “uses” the color never re-rendered. It never re-rendered because 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 visual feedback, then commits the chosen value to React state (and persistence) when the user releases. The visual channel carries the drag, and 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 has no idea it happened. 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 tell React, committing 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 wired to one input: paint on every frame for instant feedback, record once on commit. Sixty smooth repaints, one re-render.
The read side has a few subtleties, and the first one trips up most people. getPropertyValue hands you back a string, and the instinct is to expect stray whitespace: you wrote --brand with a space after the colon, so surely that space comes back glued to the front of the value. You reach for .trim() on reflex, or you compare with === expecting it to fail. Predict what actually happens before you run it, because the result is the opposite of what you expected.
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');The two leading spaces are gone. getComputedStyle().getPropertyValue() returns the custom property’s resolved value, and the browser trims leading and trailing whitespace when it resolves it — so raw is "#2563eb", seven characters, and the === you braced to fail actually passes. The JSON.stringify is what would expose any surviving space (it would show as " #2563eb" inside the quotes); here it confirms there is none. The whitespace the browser does not touch is whitespace inside a multi-token value — setProperty('--shadow', '0 1px 3px #000') reads back with that internal double space intact, because there the space is part of the value, not edge padding. So the real rule is sharper than “always trim”: edges are already trimmed for you; reach for .trim() only when you genuinely can’t trust the source, and normalize internal whitespace yourself if you’re comparing multi-part values.
So the reflexive .trim() is usually redundant for a single-token value, because the browser already trimmed the edges. The real mistake is the inverse one: assuming a space survives and writing a comparison that “defends” against whitespace that was never there. Where whitespace genuinely causes trouble is inside a multi-part value, and .trim() can’t reach that, so you normalize it yourself.
Two smaller details round this out. First, custom-property names are case-sensitive: --Brand and --brand are two different properties, so mixing the casing fails with no error to tell you why. Second, var() takes an optional fallback: var(--x, 1rem) uses 1rem when --x isn’t set. In a project that owns its tokens you rarely need that fallback, since it exists mostly for consuming tokens you didn’t define and can’t guarantee. Know the syntax, but reach for it almost never.
One more use case is worth flagging, because it is the same problem you already cured once. 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. That is 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 the page never renders in the wrong theme. Same problem, same fix, so treat this as recognition rather than new ground.
Writing tokens from React with style={{}}
Section titled “Writing tokens from React with style={{}}”The JS API is the imperative path. Inside a component you almost always want the declarative one, and React gives custom properties a first-class shape.
You write them in the style prop, exactly like any other style, just with the -- name as the key.
<div style={{ '--card-padding': '1.5rem' }} className="p-[var(--card-padding)]"> {children}</div>Two halves work together here. The inline style declares the custom property on this element, which is the React equivalent of setProperty. The p-[var(--card-padding)] utility reads it. And the value can differ on every instance, which is exactly what static utilities cannot do: p-6 is p-6 on every element 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 is how you read a custom property that isn’t on a @theme namespace, including any runtime variable you set with inline style. Use it sparingly. It is rare in component code, because almost everything you style comes from a semantic token you already have. It earns its place only for genuine one-offs, such as a single element whose width or transform is driven by a runtime value. The semantic token is the rule, and the arbitrary var() is the exception.
This is the right moment to settle an apparent contradiction. When you learned about the cascade, the rule was never to reach for inline style to win a static conflict, because that sidesteps the whole layer system. Writing a custom property in style is the legitimate inverse of that. Inline style is the correct and only tool for a dynamic, per-instance value, because no static class can carry a value computed at render time. The two rules don’t clash: use the cascade for a static conflict, and inline style for a per-instance value. 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.
When the two borders diverge, you have seen the whole point: one component definition, a token value that changes per instance, driven by a prop through a custom property. That is the bridge from “props go into JSX” to “props go into the design system.”
Designing the token system: primitive, semantic, component
Section titled “Designing the token system: primitive, semantic, component”You can now read a custom property, write it from CSS, JavaScript, and React, and scope it to any subtree. The substrate is yours. The remaining question is the architectural one, and this is where the real senior work lives: how do you organize hundreds of these so a design system stays changeable as the product grows?
The answer the industry converged on is three tiers, and the clearest way to understand them is to introduce each tier through a problem the previous one can’t solve. The named design decisions you are organizing have a cross-tool name, design tokens , and the three tiers are how you keep them from decaying as the system grows.
Tier one, primitives: the raw palette. --gray-50 through --gray-950, --blue-500, --spacing-4. These are values, not meanings: the literal colors and spacings, with names that describe what they look like. This is Tailwind’s default palette, the one you got for free the moment you imported Tailwind. A primitive knows it is blue; it has no idea what blue is for.
That is the problem the next tier solves.
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 it happens to resolve to --blue-600 today. This is the tier components read. It is exactly the token set shadcn ships: the bg-primary / bg-destructive / bg-muted vocabulary you will meet in full when you bring shadcn into the project later. And it is why the dark swap worked. .dark doesn’t touch any component; it re-points the semantic token at a different primitive (--color-primary goes from --blue-600 to --blue-400), and every bg-primary follows the binding to the new color.
Tier three, component tokens: the rare exception. --button-primary-bg, --card-padding. A token scoped to a single component, for the genuine cases where the semantic tier can’t express what one component needs. Reach for this tier rarely: most components live their whole lives on the semantic tier and never define a component token. When you see one, it should read as a deliberate exception, not a default.
The resolution chain is the core of all this, so watch it run.
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. Look at the dark strand. The bg-primary and the component are the same, but the middle pointer now aims at a lighter primitive, so the button lands on a different color with the component untouched. The component never knows. That is the property you are buying.
That chain makes the central rule clear, and it is the reflex this whole section builds toward: components reference the semantic tier, never primitives. So bg-primary, never bg-blue-600. Direct primitive use in a component is a code smell. The reason is a production story you can picture: a rebrand means changing the semantic-to-primitive pointer in one place, and the whole app re-themes. If components hard-code bg-blue-600 instead, a rebrand becomes a find-and-replace across the entire codebase that is never quite complete, because there is always one bg-blue-600 hiding in a component nobody opened this quarter, still blue after everything else turned purple. The indirection is what 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);}That --color-primary: var(--blue-600), a custom property whose value is another custom property, is the indirection made literal. 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.
--blue-500--gray-900--spacing-4--color-primary--color-foreground--color-destructive--radius-md--button-primary-bg--card-paddingIf you can sort those cleanly, you have the skill the chapter was after: read any var(--token) in the project and name which tier it belongs to.
Naming tokens for purpose, not appearance
Section titled “Naming tokens for purpose, not appearance”Token systems most often fall apart at the naming layer, so naming earns its own short section. Treat the following as decisions, not style preferences.
Pair every surface with its foreground. The convention is --color-{role} plus 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 guarantees that text on a colored surface stays legible no matter how the surface color changes, so you never put primary text on a primary background by accident. This is how shadcn keeps every surface readable, and you’ll see the full set when you adopt it.
Express states with opacity, not new tokens. A hover state is usually bg-primary/80, the same token at 80% opacity, rather than a separate --color-primary-hover token. That means fewer tokens to define, fewer to keep in sync, and the same result. It is the 2026 default: reach for the opacity modifier before you mint a state token.
The same purpose rule covers every namespace. --spacing-{role}, --radius-{role}, --shadow-{role} are all named for what they are for, never for what they are.
And here is the rule under all of it: the 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 the brand refresh next quarter makes destructive actions orange, a token named --color-red now holds an orange value. That is a contradiction you have to read past every time, and renaming the token is a breaking change that ripples through every component that referenced it. The token name is an API. Name it after the durable thing, the purpose, rather than the volatile thing, the color, and it survives the rebrand that the color never will.
@property: giving a token a type
Section titled “@property: giving a token a type”One last, narrow corner is worth knowing at the recognition level, for the one situation that calls for it.
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. And because it can’t tell what kind of value it holds, it can’t interpolate it, so a CSS transition or animation on a bare custom property snaps from one value to the next instead of animating between them. That is the problem @property solves: it tells the browser the type, so the browser can interpolate.
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, and it is required unless syntax is the universal *. Once registered, the browser knows --gradient-angle is an angle and will smoothly interpolate it from 0deg to 360deg instead of jumping.
The 2026 use for this is genuinely narrow: animatable custom properties. A gradient angle that spins, or a custom property that drives a transform and needs to interpolate between values, is what @property is for. For a static token, plain @theme and :root are all you need; @property only earns its keep when the value must animate. The instinct to carry away is a debugging one: if a token animates but snaps instead of easing, it is an unregistered custom property. The actual keyframe and transition syntax comes later, when the chapter on motion arrives. Here you only need to know that the enabling primitive exists and what crossing that threshold looks like.
External resources
Section titled “External resources”The canonical references and a couple of deeper dives for everything in this lesson:
MDN's canonical guide to declaring, reading, scoping, inheriting, and falling back on custom properties.
CSS-Tricks' deep, pattern-heavy tour of splitting values, runtime updates, and practical production uses.
web.dev on why typing a custom property is what unlocks interpolation for animating gradients and angles.
MDN's at-rule reference for syntax, inherits, and initial-value when registering a typed custom property.