
Mastering CSS Custom Properties (Variables) for Scalable Design Systems
Almost every CSS codebase uses custom properties by now. Most of them use them the same way: a list of colors at the top of a stylesheet, referenced with var() wherever needed. That's useful, but it barely scratches the surface. CSS custom properties are live values that cascade, inherit, and can be changed at runtime by CSS or JavaScript. Those traits make them the ideal foundation for a design system that has to support multiple themes, dozens of components, and a team of people changing things at once.
This guide goes beyond the basics. We'll cover how custom properties actually behave, how to structure tokens in layers, how to build themeable components, and how to use @property to get typed, animatable variables.
Custom Properties vs. Preprocessor Variables
If you came to CSS variables from Sass or Less, it's worth understanding the fundamental difference.
A Sass variable is compiled away. $brand: #38bdf8; gets replaced with the literal value at build time. The browser never sees it, and it can't change after the page loads.
A CSS custom property is a real property on real elements. It participates in the cascade, inherits down the DOM tree, and can be overridden by media queries, selectors, or JavaScript while the page is running.
:root {
--space: 1rem;
}
.compact {
--space: 0.5rem;
}
.card {
padding: var(--space);
}
A .card inside a .compact region gets smaller padding automatically. There's no need for a .card--compact modifier. That scoping behavior is the single most important feature for design systems.
The Basics, Precisely
A custom property is any property whose name starts with two dashes. Its value is read with var():
.button {
--button-bg: #0f172a;
background: var(--button-bg);
}
A few rules that matter in practice:
- Names are case-sensitive.
--Brandand--brandare different properties. - Values are stored as tokens, not parsed until used.
--size: 20px 40px;is valid even though it's not a valid value for most properties. Validity is only checked whenvar()substitutes it somewhere. - They inherit by default. Set a value on a parent and every descendant sees it unless overridden.
Fallback values
var() accepts a fallback as the second argument:
.alert {
color: var(--alert-color, #b91c1c);
}
If --alert-color isn't defined, the fallback is used. Fallbacks can be nested: var(--a, var(--b, red)). Everything after the first comma is the fallback, so var(--font, Inter, sans-serif) works as expected.
The invalid-at-computed-value-time trap
This one catches everyone eventually:
:root {
--gap: red;
}
.stack {
gap: 1rem;
gap: var(--gap);
}
You might expect the browser to discard the second declaration and keep 1rem. It doesn't. When a var() is present, the browser can't validate the declaration at parse time, so it accepts it. At computed-value time, red turns out to be an invalid gap, and the property becomes invalid at computed-value time: it falls back to its inherited or initial value (normal for gap), not your earlier declaration. Registering the property with @property (covered later) is one way to get more predictable behavior.
Structuring Tokens in Layers
The biggest mistake teams make is having a single flat list of variables that components reference directly. It works at first, but it makes theming painful and renames risky. A more scalable approach is to organize tokens into three tiers.
Tier 1: Primitive tokens
Raw values with no meaning attached. They describe what something is, not where it's used.
:root {
--blue-50: #eff6ff;
--blue-500: #3b82f6;
--blue-700: #1d4ed8;
--slate-50: #f8fafc;
--slate-600: #475569;
--slate-900: #0f172a;
--size-1: 0.25rem;
--size-2: 0.5rem;
--size-3: 0.75rem;
--size-4: 1rem;
--size-6: 1.5rem;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
}
Tier 2: Semantic tokens
These map primitives to purposes. They're what themes override.
:root {
--color-bg: var(--slate-50);
--color-text: var(--slate-900);
--color-text-muted: var(--slate-600);
--color-accent: var(--blue-500);
--color-accent-strong: var(--blue-700);
--space-inline: var(--size-4);
--space-stack: var(--size-3);
--radius-control: var(--radius-md);
}
Tier 3: Component tokens
Each component exposes its own knobs, defaulting to semantic tokens.
.button {
--button-bg: var(--color-accent);
--button-bg-hover: var(--color-accent-strong);
--button-text: #ffffff;
--button-radius: var(--radius-control);
--button-padding: var(--size-2) var(--size-4);
background: var(--button-bg);
color: var(--button-text);
border-radius: var(--button-radius);
padding: var(--button-padding);
border: 0;
}
.button:hover {
background: var(--button-bg-hover);
}
The benefit is that each tier can change independently. Swap out the blue palette and every semantic token updates. Change --color-accent in a theme and every component follows. Need a one-off pill button? Override --button-radius on that instance without touching the component.
<button class="button" style="--button-radius: 999px">Subscribe</button>
Theming with Custom Properties
With semantic tokens in place, themes become a matter of re-declaring a handful of values.
Dark mode
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--slate-900);
--color-text: var(--slate-50);
--color-text-muted: #94a3b8;
--color-accent: #60a5fa;
}
}
/* Manual override set on <html data-theme="dark"> */
:root[data-theme="dark"] {
--color-bg: var(--slate-900);
--color-text: var(--slate-50);
--color-text-muted: #94a3b8;
--color-accent: #60a5fa;
}
You can also use the light-dark() function, which is supported in all current major browsers, to declare both values at once. It depends on color-scheme being set:
:root {
color-scheme: light dark;
--color-bg: light-dark(#f8fafc, #0f172a);
--color-text: light-dark(#0f172a, #f8fafc);
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
Scoped themes
Because custom properties cascade, a theme doesn't have to be global. Any subtree can have its own:
.theme-inverse {
--color-bg: var(--slate-900);
--color-text: var(--slate-50);
--color-accent: #fbbf24;
}
Wrap a promo banner in .theme-inverse and every button, card, and link inside it adapts. This is how you get "dark section on a light page" without duplicating component styles.
Brand themes
Multi-brand products (white-label SaaS, agency sites, product families) can ship one component library and one token file per brand:
[data-brand="harbor"] {
--color-accent: #0ea5e9;
--radius-control: 2px;
--font-heading: "Fraunces", serif;
}
[data-brand="grove"] {
--color-accent: #16a34a;
--radius-control: 12px;
--font-heading: "Inter", sans-serif;
}
Deriving Values with calc() and Color Functions
Custom properties become far more powerful when combined with math. A spacing scale can be generated from a single base:
:root {
--space-base: 0.25rem;
--space-2: calc(var(--space-base) * 2);
--space-4: calc(var(--space-base) * 4);
--space-8: calc(var(--space-base) * 8);
}
A type scale can use a ratio:
:root {
--text-base: 1rem;
--text-ratio: 1.25;
--text-lg: calc(var(--text-base) * var(--text-ratio));
--text-xl: calc(var(--text-lg) * var(--text-ratio));
--text-2xl: calc(var(--text-xl) * var(--text-ratio));
}
For colors, relative color syntax lets you derive shades from one base token. It's supported in current versions of all major browsers, though support arrived relatively recently, so check your audience:
:root {
--accent: oklch(62% 0.19 250);
--accent-hover: oklch(from var(--accent) calc(l - 0.08) c h);
--accent-soft: oklch(from var(--accent) l c h / 0.15);
}
For wider compatibility, color-mix() achieves similar results and has been supported a bit longer:
:root {
--accent: #3b82f6;
--accent-hover: color-mix(in oklch, var(--accent), black 15%);
--accent-soft: color-mix(in srgb, var(--accent) 15%, transparent);
}
Change --accent once and the hover and soft variants follow.
Typed Properties with @property
By default, the browser treats a custom property's value as an untyped sequence of tokens. That's why you can't smoothly transition one: the browser doesn't know that --angle: 0deg and --angle: 180deg are angles it could interpolate between. @property fixes that by registering the property with a syntax, an inheritance flag, and an initial value.
@property --gradient-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.hero {
background: linear-gradient(var(--gradient-angle), #38bdf8, #818cf8);
transition: --gradient-angle 600ms ease;
}
.hero:hover {
--gradient-angle: 180deg;
}
The gradient now rotates smoothly on hover, which was impossible with untyped custom properties. @property is supported in all current major browsers.
Registration brings other benefits for design systems:
- Type safety. If someone sets
--gradient-angle: blue, the value is invalid and the registeredinitial-valueis used instead of breaking the declaration. - Controlled inheritance. Setting
inherits: falsestops component-level tokens from leaking into nested components. A--button-bgset on a card won't accidentally restyle a button nested deeper, unless you set it on the button itself. - Performance. Non-inheriting properties avoid some style recalculation work when they change on large subtrees.
A registered component token might look like this:
@property --card-padding {
syntax: "<length>";
inherits: false;
initial-value: 1.5rem;
}
Working with JavaScript
Custom properties are the cleanest bridge between JavaScript state and CSS. Instead of setting individual inline styles, set one variable and let CSS decide how to use it:
const tracker = document.querySelector(".spotlight");
tracker.addEventListener("pointermove", (event) => {
const rect = tracker.getBoundingClientRect();
tracker.style.setProperty("--x", `${event.clientX - rect.left}px`);
tracker.style.setProperty("--y", `${event.clientY - rect.top}px`);
});
.spotlight {
--x: 50%;
--y: 50%;
background: radial-gradient(
circle at var(--x) var(--y),
rgb(56 189 248 / 0.35),
transparent 40%
);
}
To read a value, use getComputedStyle:
const accent = getComputedStyle(document.documentElement)
.getPropertyValue("--color-accent")
.trim();
Naming Conventions
Consistency matters more than any specific convention, but a few patterns scale well:
- Prefix by category:
--color-,--space-,--radius-,--font-,--shadow-,--z-. - Prefix component tokens with the component name:
--button-bg,--card-padding. - Name semantic tokens by role, not appearance:
--color-dangerinstead of--color-red, so a theme can make danger orange without the name lying. - Avoid encoding values in names:
--space-16pxbecomes wrong the moment someone changes it.
Common Pitfalls
Referencing primitives directly in components. background: var(--blue-500) in a button bypasses the semantic layer, so themes can't change it. Always go through a semantic or component token.
Circular references. --a: var(--b); --b: var(--a); makes both invalid at computed-value time. This happens more often than you'd think when refactoring token names.
Unintended inheritance. An unregistered --button-bg set on a wrapper cascades into every nested button, including ones in unrelated components. Use @property with inherits: false or keep component tokens declared on the component itself.
Using custom properties in media queries. @media (min-width: var(--bp-md)) doesn't work. Media queries are evaluated outside the element tree, where custom properties don't exist. Use a build-time tool or hardcode breakpoints.
Forgetting units in math. --gap: 16; then calc(var(--gap) * 1px) works, but calc(var(--gap) + 1rem) doesn't. Be explicit about when a token is a number versus a length.
Conclusion
Custom properties are the foundation of a modern CSS design system. Organized into primitive, semantic, and component tiers, they let you theme globally or per section, give components safe override points, and change the whole system from a handful of values. Combine them with calc(), color-mix(), and relative colors to derive scales from single sources, and use @property for typed, animatable, non-leaking tokens.
The result is a system where changing your brand color is a one-line edit, dark mode is a dozen declarations, and components adapt to their context instead of needing a new modifier class for every situation. That's what makes a design system scale.


