CSS custom properties (--name: value, read with var()) are not just "CSS variables" — unlike a Sass variable, which is erased at compile time, a custom property lives in the DOM, cascades, inherits, and can be updated at runtime without a rebuild. That single difference is why they're the right foundation for a design token system, and this guide covers the two-layer token model, naming, scoping, theming, and the fallback behavior that trips people up. Teams doing standards-compliant web development at YuSMP treat a token system as a contract, not a decoration — the same discipline applies here.
A Sass or LESS variable is a find-and-replace trick: the preprocessor swaps $primary-color for its value and the variable itself disappears from the shipped CSS. A custom property is different in kind. Declare one and it becomes a real, inheritable value in the cascade:
:root {
--color-blue-600: #0098f2;
}
.button {
background: var(--color-blue-600);
}Because it's part of the cascade, a custom property can be redefined at any level of the DOM — a media query, a data attribute, a parent element — and every descendant that reads it with var() updates immediately, with no JavaScript and no rebuild step. That's the capability a compile-time variable simply cannot offer, and it's the reason design systems are migrating their token layer off Sass and onto native custom properties.
Mature design systems split tokens into two layers instead of one flat list. Primitive tokens hold raw values with no opinion about where they're used — --color-blue-600: #0098f2. Semantic tokens point at a primitive but name a role — --color-interactive: var(--color-blue-600). Components only ever read semantic tokens:
:root {
/* primitive */
--color-blue-600: #0098f2;
--color-gray-900: #0d111b;
/* semantic */
--color-interactive: var(--color-blue-600);
--color-text: var(--color-gray-900);
}
.link { color: var(--color-interactive); }The indirection is the point. When the brand color changes, you edit one primitive and every semantic token built on it updates. When a component needs a different role — a link that should read as "interactive" but render differently on a dark card — you redefine the semantic token at that scope, and no component CSS has to change at all.
A token system fails long before it runs out of colors; it fails when nobody can guess a token's name. The pattern that holds up is hierarchical and boring on purpose: --<category>-<property>-<variant>, for example --color-bg-subtle, --space-md, --radius-lg. Consistency beats cleverness every time — a predictable, slightly verbose name that a new engineer can guess correctly beats a short one that only the author remembers.
Global tokens belong on :root, where every element in the document can read them through inheritance. Component-level tokens belong on the component's own selector, scoped to override just that subtree:
.card {
--card-padding: 24px;
padding: var(--card-padding);
}
.card.compact {
--card-padding: 12px;
}This is the gap most writing on design tokens skips: a custom property a component exposes on its own root is effectively a public API, the same way a prop or an attribute is. --card-padding is a contract — "you may override this, and only this, from outside" — while everything else inside the component stays encapsulated. Treat the set of custom properties a component declares as its interface boundary, document it the same way you'd document a prop list, and the rest of the token system stays legible as it grows.
Because custom properties cascade, a full theme swap is just redefining the same semantic tokens inside a different scope — no state, no re-render, no JS theme switcher:
:root {
--color-bg: #ffffff;
--color-text: #0d111b;
}
[data-theme="dark"] {
--color-bg: #0d111b;
--color-text: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root { --color-bg: #0d111b; --color-text: #ffffff; }
}Every component that reads var(--color-bg) or var(--color-text) repaints the instant the attribute or media query flips. The theme logic lives entirely in CSS; the only JavaScript you need, if any, is a one-line toggle for the data-theme attribute itself.
var() takes an optional second argument: var(--x, fallback). If --x is unset — not empty, genuinely never declared anywhere in scope — the fallback value is used instead. This matters in two concrete situations: an optional theme token that a smaller or older theme never defines, and third-party embeds or widgets that render inside your page without access to your token sheet. Give those cases an explicit fallback and the component degrades gracefully instead of silently rendering with unset, which for most properties behaves like the property was never applied at all.
.widget {
background: var(--widget-bg, #f3f2ef);
}Yes, without qualification — custom properties and var() have shipped in every evergreen browser for years and need no fallback or feature query. The one genuinely new piece is @property, which lets you register a custom property with a type, an initial value, and whether it inherits — typed custom properties that the browser can now interpolate and animate correctly, something plain untyped custom properties can't do. Support has matured across Chromium and Firefox; treat it as a forward-looking addition for properties you need to animate, not a requirement for the token system itself.
A Sass variable is replaced at compile time and doesn't exist in the shipped CSS. A CSS custom property is a real, live value in the DOM — it cascades, inherits, and can be redefined at runtime (in a media query, a data attribute, a different scope) with no rebuild.
:root or be scoped to components?Both. Global, system-wide tokens (brand colors, spacing scale, type sizes) belong on :root. Tokens that only one component needs to expose for overriding — like --card-padding — belong scoped to that component's own selector.
Yes. That's one of their advantages over Sass variables: no compiler is required to define, read, or override them. A build tool (Style Dictionary and similar) is useful once you need to generate tokens for multiple platforms from one source, but it's optional for CSS-only use.
Because the theme values live in the cascade, flipping a data-theme attribute or matching a prefers-color-scheme query redefines the tokens and every component that reads them repaints immediately. There's no component state to manage and no re-render to trigger.
var(--x, fallback) actually do, and when do I need it?It supplies a value to use if --x was never declared in any reachable scope. Use it for optional tokens that not every theme defines, and for components that might render without access to your token sheet, such as third-party widgets.
@property) usable in production yet?Yes, across current Chromium and Firefox. They're most valuable when you need the browser to interpolate or animate a custom property correctly; for a standard, static token system, plain custom properties remain the default choice.
Yes — redefining a custom property inside a media query or a container query is the standard pattern for responsive and theme-aware design systems, and it's exactly how the dark-mode example above works.