CSS
    themingfrontend

    CSS Custom Properties as Design Tokens: A Practical System

    Dark mode and user-configurable themes break the moment your design tokens are Sass variables baked in at build time. Here's a practical three-tier system built on native custom properties.

    Editor: Paul RadfordMay 25, 20267 min read

    Paul RadfordFull-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

    CSS Custom Properties as Design Tokens: A Practical System

    CSS Custom Properties can serve as design tokens by storing values like colors, spacing, and type scales as reusable, cascading variables that CSS, JavaScript, and even inline styles can read and override. The practical system pairs them with a naming convention, a layered scoping strategy, and a build step for exporting tokens to other platforms.

    Why Not Just Use Sass Variables?

    Sass variables get compiled away. Once the build finishes, $primary-color is gone, replaced by a static hex value baked into every selector that used it. That's fine for a site that never changes its theme at runtime, but it breaks down the moment you need dark mode, user-configurable density, or a client who wants to swap brand colors without redeploying.

    Custom Properties live in the actual DOM. They cascade, they can be read and written by JavaScript, and they respond to media queries and container queries without a rebuild. The tradeoff is that they cost a little more at paint time because the browser has to resolve them during style computation rather than once at build time, and they lack Sass's compile-time features like real functions, loops, and type checking (a Custom Property is always a string until something interprets it).

    The practical answer for most production systems in 2026 is to use both: Sass or a JS build step to generate and organize token definitions, and Custom Properties as the runtime delivery mechanism. You get authoring convenience and runtime flexibility at the same time.

    Setting Up a Token Layer That Won't Fight You Later

    The biggest mistake teams make is defining every token at :root and calling it done. That works for a demo but collapses under real theming requirements. A better structure separates primitive tokens (raw values) from semantic tokens (purpose-driven names that reference primitives).

    css
    1/* Primitives: raw, unopinionated values.
    2 Never use these directly in components. */
    3: root {
    4 --color-blue-500: #2563eb;
    5 --color-blue-600: #1d4ed8;
    6 --color-gray-100: #f3f4f6;
    7 --color-gray-900: #111827;
    8
    9 --space-1: 0.25rem;
    10 --space-2: 0.5rem;
    11 --space-4: 1rem;
    12 --space-8: 2rem;
    13}
    14
    15/* Semantic layer: what components actually consume.
    16 This is the layer you re-map per theme. */
    17: root {
    18 --color-bg: var(--color-gray-100);
    19 --color-text: var(--color-gray-900);
    20 --color-action: var(--color-blue-500);
    21 --color-action-hover: var(--color-blue-600);
    22
    23 --spacing-sm: var(--space-2);
    24 --spacing-md: var(--space-4);
    25 --spacing-lg: var(--space-8);
    26}
    27
    28/* Dark theme only overrides the semantic layer,
    29 never touches components or primitives. */
    30[data-theme="dark"] {
    31 --color-bg: var(--color-gray-900);
    32 --color-text: var(--color-gray-100);
    33 --color-action: var(--color-blue-600);
    34}

    This two-layer split matters because it isolates the blast radius of a theme change. Components should reference --color-action, never --color-blue-500. If a designer decides the brand blue needs to shift, you edit one primitive and every semantic token that depends on it updates automatically, without touching component CSS at all.

    How Do You Scope Tokens Without Leaking Global State?

    Global :root tokens work for app-wide defaults, but component libraries need scoped overrides that don't bleed into unrelated parts of the page. Custom Properties inherit down the DOM tree, so you can redefine a token on any element and every descendant picks up the new value unless it's overridden again further down.

    css
    1/* Component-level token scoping.
    2 Card sets its own spacing/color defaults,
    3 but still allows page-level overrides to win. */
    4.card {
    5 --card-padding: var(--spacing-md);
    6 --card-bg: var(--color-bg);
    7
    8 padding: var(--card-padding);
    9 background: var(--card-bg);
    10 border-radius: 0.5rem;
    11}
    12
    13/* A "compact" variant only overrides the token,
    14 not the padding property itself. */
    15.card--compact {
    16 --card-padding: var(--spacing-sm);
    17}
    18
    19/* Consumers can override without knowing card internals */
    20.sidebar .card {
    21 --card-bg: transparent;
    22}

    This pattern (sometimes called "local custom properties as component API") is more flexible than exposing a dozen modifier classes. The component defines sensible defaults, but any ancestor can inject a different value through the cascade. The gotcha: because Custom Properties inherit, a typo like --card-pading doesn't throw an error. It just silently fails to override anything, and the browser falls back to the initial value or the last valid one in the cascade. There's no dev-tools warning for this the way there is for a genuinely invalid CSS property, so a systematic naming convention (and linting via a tool like Stylelint's custom-property-pattern rule) pays for itself quickly.

    Handling Fallbacks and Invalid Values

    var() accepts a second argument as a fallback, which is essential when a token might not be defined in a given context (a third-party embed, an older browser, a partial theme).

    css
    1.button {
    2 /* If --color-action is undefined anywhere in the
    3 cascade, fall back to a hardcoded blue. */
    4 background: var(--color-action, #2563eb);
    5
    6 /* Fallbacks can chain another var() call */
    7 color: var(--color-on-action, var(--color-text, white));
    8}

    One nuance worth knowing precisely: if a Custom Property is defined but set to an invalid value for the property using it (for example --space-4: not-a-length; used in a margin), the browser does not fall back to the var() fallback argument. Instead it treats the property as invalid at computed-value time, and the affected property resolves to its initial or inherited value, per the CSS Custom Properties specification. This trips people up constantly: they assume the fallback in var(--space-4, 1rem) is a safety net for bad values, but it only kicks in when the property is entirely unset, not when it's set to garbage.

    Exposing Tokens to JavaScript

    Because Custom Properties are live DOM values, JavaScript can read and write them directly, which makes them useful for things like theme switchers, animation-driven values, or user preference controls that shouldn't require a CSS rebuild.

    javascript
    1// Read a token's current resolved value
    2const root = document.documentElement;
    3const styles = getComputedStyle(root);
    4const currentBg = styles.getPropertyValue('--color-bg').trim();
    5
    6// Write a new value, triggering a repaint wherever
    7// the token is consumed
    8root.style.setProperty('--color-action', '#7c3aed');
    9
    10// Common pattern: toggle a data attribute and let
    11// CSS handle the actual value swap, rather than
    12// setting dozens of properties from JS
    13document.documentElement.dataset.theme =
    14 document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';

    Prefer the data-theme toggle approach over setting many individual properties from JavaScript. It keeps the source of truth in CSS, avoids inline style specificity issues, and means your token definitions stay reviewable in one file instead of scattered across JS logic.

    When Custom Properties Are the Wrong Tool

    They're not a replacement for a full design token pipeline if you need to output values to native iOS, Android, or design tools like Figma. For that, a JSON-based token source (following something close to the W3C Design Tokens Community Group format) with a build step (Style Dictionary is the common choice) that generates CSS Custom Properties as one of several output targets is the more mature approach. Treat CSS Custom Properties as the runtime layer for the web target, not the canonical source of truth for a multi-platform design system.

    They're also a poor fit for values that need real computation, like generating a full type scale from a ratio, or math that needs to happen before paint. calc() handles simple arithmetic well, but anything more complex belongs in the build step that generates your primitive tokens, not in the browser at runtime.

    Finally, be aware of the animation caveat: Custom Properties are untyped strings by default, so you cannot smoothly animate or transition between two values like 10px and 50px unless you register the property's type. The @property at-rule, part of the CSS Properties and Values API, lets you declare a syntax (like <length>) so the browser knows how to interpolate it. Support is solid in Chromium and Firefox as of Firefox 128 (mid-2024), and in Safari since 16.4, but if you need to support older WebKit or any Chromium build before roughly version 85, animating a raw custom property will just snap between values instead of transitioning.

    Testing the System Before You Ship It

    Before rolling a token system into production, check three things directly in the browser rather than trusting the CSS to "look right." Open DevTools, inspect a themed component, and confirm the Computed panel shows the resolved value you expect, not just the var() reference, since a broken inheritance chain often still displays a plausible-looking fallback that masks the real bug. Toggle your theme attribute manually in the Elements panel and watch for any component that doesn't update, which usually means a token was hardcoded somewhere instead of referencing the semantic layer. Then throttle to an older engine or use a tool like caniuse to confirm your fallback values actually apply in the browsers your analytics say you still support, because a silent fallback failure is invisible until a real user hits it.

    Related Articles