CSS Custom Properties: A Practical Engineering Decision for Design Systems
Define design tokens once on :root, reference them with var(), and swap themes by changing a single data-theme attribute—no rebuild needed.
01 May 2026, 13:55 UTC

The Problem: Design Tokens Without a Build Step
You're maintaining a component library that needs to support light mode, dark mode, and a high-contrast accessibility theme. Historically, this meant Sass variables compiled into separate CSS bundles, or a mess of utility classes like .bg-brand-light and .bg-brand-dark that bloat the stylesheet and require HTML changes to switch themes.
CSS Custom Properties (often called CSS variables) solve this differently: they're live values in the browser, resolved at computed-value time, and they inherit through the cascade. That means you can define a token palette once on :root, reference it everywhere with var(--color-brand), and swap the entire theme by changing one attribute on <html> — no rebuild, no class toggling on every component.
How They Differ from Preprocessor Variables
Sass or Less variables are replaced at compile time. The browser never sees $primary; it sees the literal #2563eb that was substituted during build. Custom properties, by contrast, exist in the computed style. You can inspect them in DevTools, read them from JavaScript, and change them at runtime:
// JavaScript can read and write them live
document.documentElement.style.setProperty('--color-brand', '#1d4ed8');
console.log(getComputedStyle(document.documentElement).getPropertyValue('--color-brand'));
// "#1d4ed8"
This enables theme switching that feels instantaneous because the browser's style engine does the work — no CSS regeneration, no DOM mutation beyond a single attribute change.
A Token Palette Pattern That Scales
The engineering pattern that's emerged across design systems (GitHub's Primer, Adobe's Spectrum, Microsoft's Fluent) is straightforward:
- Define a global token palette on
:rootusing a consistent naming convention (kebab-case, category-scale). - Component stylesheets reference only tokens, never raw values.
- Theming becomes swapping a
[data-theme]block that overrides the palette.
/* tokens.css — single source of truth */
:root {
--color-brand: #2563eb;
--color-brand-hover: #1d4ed8;
--color-text: #111827;
--color-background: #ffffff;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-8: 2rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
}
/* Dark mode override — only what changes */
[data-theme="dark"] {
--color-brand: #3b82f6;
--color-brand-hover: #60a5fa;
--color-text: #f9fafb;
--color-background: #111827;
}
/* High contrast override */
[data-theme="high-contrast"] {
--color-brand: #0000ee;
--color-text: #000000;
--color-background: #ffffff;
}
Component files stay clean and theme-agnostic:
/* button.css — no theme logic here */
.button {
background-color: var(--color-brand);
color: var(--color-background);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
transition: background-color 0.15s ease;
}
.button:hover {
background-color: var(--color-brand-hover);
}
Worked Example: Runtime Theme Switcher
Here's a complete, minimal implementation you can drop into any project. It uses a data-theme attribute on <html>, persists the choice to localStorage, and respects the user's system preference as a default.
<!-- index.html -->
<html data-theme="light">
<head>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="components.css">
</head>
<body>
<button class="button" id="theme-toggle">Toggle Theme</button>
<script src="theme.js"></script>
</body>
</html>
/* theme.js */
const THEME_KEY = 'app-theme';
const html = document.documentElement;
// Initialize from localStorage or system preference
function initTheme() {
const stored = localStorage.getItem(THEME_KEY);
if (stored) {
html.dataset.theme = stored;
return;
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
html.dataset.theme = prefersDark ? 'dark' : 'light';
}
function toggleTheme() {
const current = html.dataset.theme;
const next = current === 'dark' ? 'light' : 'dark';
html.dataset.theme = next;
localStorage.setItem(THEME_KEY, next);
}
initTheme();
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
Clicking the button flips data-theme between light and dark. Every component using var(--color-brand), var(--color-text), etc., updates instantly. No re-render, no class churn.
Trade-offs and Limitations You'll Hit
Performance: Unique Values Create Style Recalculation Work
Each distinct custom property value on an element creates a new computed style entry. If you set --index on 500 list items for staggered animations, the browser recalculates style for each unique value. Prefer scoping changes to containers:
/* Expensive: 500 unique computed styles */
li { --delay: calc(var(--index) * 100ms); }
/* Cheaper: one container change, descendants inherit */
.list { --stagger-base: 100ms; }
li { animation-delay: calc(var(--index) * var(--stagger-base)); }
Profile with the Performance panel: look for "Recalculate Style" events after bulk changes.
@property Support Gaps Affect Animation
Without the @property rule (Houdini), custom properties animate as discrete flips, not smooth transitions. @property is supported in Chromium 85+, Firefox 129+, Safari 16.4+. Feature-detect before relying on smooth interpolation:
@supports (@property --test) {
.button {
--color-brand: #2563eb;
transition: --color-brand 0.2s ease;
}
.button:hover { --color-brand: #1d4ed8; }
}
/* Fallback for older browsers */
@supports not (@property --test) {
.button { transition: background-color 0.2s ease; }
}
Fallbacks Evaluated at Parse Time, Not Inherit Time
var(--colr, blue) (typo) silently uses blue because the fallback is evaluated when the stylesheet is parsed, not when the variable resolves. This makes typos hard to catch. Use stylelint with the stylelint-custom-property-pattern plugin to flag undefined references in CI.
Inline Style Specificity Trap
Setting style="--color-brand: red" on an element has higher specificity than :root or [data-theme] rules. This can accidentally lock a component out of theme changes. Prefer selector-based overrides:
/* Instead of inline style on the component */
.custom-card { --color-brand: #dc2626; }
/* Or use a data attribute scoped to a section */
[data-accent="red"] { --color-brand: #dc2626; }
Verification Checklist Before Shipping
- Open DevTools → Elements → Computed → filter "custom" to inspect resolved custom property values on any element.
- Run
getComputedStyle(document.documentElement).getPropertyValue('--color-brand')in console to verify runtime tokens. - Toggle
[data-theme]on<html>and confirm allvar()consumers update without reload. - Check
@propertysupport:CSS.supports('@property', '--test: syntax("")')in JS or@supports (@property --test)in CSS. - Profile style recalculation in the Performance panel after theme switches.
When to Adopt
If your project targets browsers from the last 7 years (Chrome 49+, Firefox 31+, Safari 9.1+, Edge 15+), custom properties are stable and prefix-free. They're the right choice when:
- You need runtime theme switching without a build step.
- Your design system spans multiple teams or codebases that share tokens.
- You want to reduce CSS bundle size by eliminating theme-specific class variants.
Stick with preprocessor variables if you're doing complex math at build time (color manipulation, grid calculations) that doesn't need to change in the browser. The two approaches aren't mutually exclusive — many teams use Sass for authoring-time logic and custom properties for the runtime token layer.
Start by extracting your existing color and spacing values into a tokens.css file, reference them with var() in one component, and verify the cascade works. The rest follows naturally.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.