CSS Custom Properties โ
A Practical Guide to Design Tokens
Most developers use CSS variables as a slightly more convenient way to store colors. That's leaving most of their power on the table. Scoping, dark mode, JavaScript integration, token architecture โ once you understand how Custom Properties actually work, you'll rethink how you structure CSS entirely.

CSS Variables vs. Sass Variables: They're Not the Same Thing
If you've been writing CSS for a while, you probably know Sass variables. The $primary-color: #059669; kind. When CSS Custom Properties arrived, a lot of developers filed them as "basically the same thing but native." They're not, and the difference matters enormously in practice.
Sass variables are resolved at compile time. By the time a browser sees your CSS, every $primary reference has already been replaced with its literal value. There's no variable left โ just static text. CSS Custom Properties, on the other hand, live in the browser. They're evaluated at runtime, they participate in the cascade, they can be changed by JavaScript, and they inherit through the DOM. They're genuinely dynamic.
// Sass: after compilation, the variable is gone
$primary: #059669;
$spacing-md: 16px;
.button {
background: $primary; // โ becomes background: #059669;
padding: $spacing-md; // โ becomes padding: 16px;
}
// No way to change this after the page loads.
/* CSS: the variable persists in the browser */
:root {
--color-primary: #059669;
--spacing-md: 16px;
}
.button {
background: var(--color-primary);
padding: var(--spacing-md);
}
/* Updatable via JavaScript at any time: */
/* document.documentElement.style.setProperty('--color-primary', '#2563eb') */
Scoping and Inheritance โ The Real Power of Custom Properties
The most underused feature of CSS Custom Properties is scoping. When you define a variable on :root, it's globally available. But you can define variables on any selector, and those definitions only apply within that element's subtree โ and they're inherited by child elements. This makes component-level theming genuinely straightforward.
The key insight: instead of writing separate style rules for every variant, you write the component once and only change the variables. The component's logic stays untouched.
/* Base variables */
:root {
--card-bg: #ffffff;
--card-border: #e5e7eb;
--card-accent: #059669;
}
/* Component uses only variables โ no hardcoded values */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-top: 3px solid var(--card-accent);
padding: 20px;
border-radius: 10px;
}
/* Variants just override the variables.
No duplication of .card's rules. */
.card--featured {
--card-bg: #ecfdf5;
--card-border: #6ee7b7;
}
.card--danger {
--card-bg: #fef2f2;
--card-border: #fca5a5;
--card-accent: #dc2626;
}
.card--info {
--card-bg: #eff6ff;
--card-border: #93c5fd;
--card-accent: #2563eb;
}
What makes this pattern so useful in a real project is maintainability. If the card design changes โ say the border radius needs to increase โ you change it in one place in .card, and every variant inherits the update automatically. You're not hunting through four blocks of duplicated CSS.
Standard card
Highlighted card
Warning card
Info card
Dark Mode the Right Way
Dark mode is where CSS Custom Properties go from "nice to have" to "genuinely transformative." The old approach โ writing a separate block of overrides for every component โ doesn't scale. With Custom Properties, you define your theme as a set of variables, and switching the theme means swapping the variable values. Components don't need to know what theme they're in.
/* Light theme (default) */
:root {
--bg-base: #ffffff;
--bg-surface: #f9fafb;
--text-primary: #111827;
--text-secondary: #6b7280;
--border: #e5e7eb;
--accent: #059669;
}
/* Dark theme: only variable values change */
@media (prefers-color-scheme: dark) {
:root {
--bg-base: #0f172a;
--bg-surface: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--border: #334155;
--accent: #34d399;
}
}
/* Components reference variables โ dark mode is automatic */
body {
background: var(--bg-base);
color: var(--text-primary);
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
}
Manual toggle with data-theme
For a user-controlled theme switch, the cleanest approach is a data-theme attribute on <html>. This also lets you offer both system-preference-based and manual switching simultaneously.
/* CSS: respond to a data attribute instead of media query */
[data-theme="dark"] {
--bg-base: #0f172a;
--text-primary: #f1f5f9;
/* ... other dark values */
}
// JavaScript: toggle the attribute on click
const toggle = document.querySelector('#theme-btn');
toggle.addEventListener('click', () => {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
html.setAttribute('data-theme', current === 'dark' ? 'light' : 'dark');
});
Only the variable values change. Components adapt automatically.
Design Tokens: Naming Is Everything
A common mistake when starting with CSS Custom Properties is naming variables after their values. It seems natural at first โ --green for your green color, --dark-green for the hover state. The problem shows up the moment you need a dark mode, or rebrand the product, or use that same "green" in a context where it's no longer green.
:root {
--green: #059669;
--dark-green: #047857;
--light-green: #6ee7b7;
}
/* Problem: in dark mode, --green might need to become lighter,
not darker. The name starts lying about the value. */
:root {
/* Primitive tokens โ the palette. Don't use these directly. */
--palette-green-200: #6ee7b7;
--palette-green-500: #059669;
--palette-green-700: #047857;
/* Semantic tokens โ role-based names. Use these in components. */
--color-accent: var(--palette-green-500);
--color-accent-hover: var(--palette-green-700);
--color-accent-subtle: var(--palette-green-200);
/* Spacing tokens */
--space-1: 4px;
--space-2: 8px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
}
The two-layer system separates what a color is (primitive) from what it does (semantic). Dark mode only needs to remap the semantic tokens โ the primitives stay the same. A complete rebrand means updating the primitive layer while the semantic names stay stable. Components never need to change.
Actually, this is worth emphasizing: invest in your token naming early. I've seen projects where halfway through development someone decides to move from value-based names to role-based ones โ and it means touching every CSS file. The naming convention you pick at the start becomes load-bearing very quickly.
JavaScript Integration: CSS and JS Without Coupling
The ability to read and write CSS Custom Properties from JavaScript opens up a genuinely clean way to coordinate CSS and JS. Instead of toggling class names and hoping the right styles fire, you can pass values directly into the cascade โ and your CSS stays in CSS.
// Read a variable value
const accentColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-accent').trim();
// โ "#059669"
// Write a variable globally
document.documentElement.style.setProperty(
'--color-accent', '#2563eb'
);
// Write a variable scoped to one element
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#eff6ff');
A practical example: mouse position effects. Instead of calculating element positions in JavaScript and setting style.left / style.top directly, you can pass the mouse coordinates as CSS variables and let CSS handle the visual response. The logic stays in JS, the animation stays in CSS.
Takeaways
- CSS Custom Properties are fundamentally different from Sass variables: they're evaluated at runtime, inherit through the DOM, and can be read and written by JavaScript.
- Scoped variables enable a powerful component pattern: write the component once, create variants by overriding variables โ no style duplication required.
- Dark mode built on Custom Properties scales cleanly: swap the variable values in a media query or data-attribute selector, and every component updates automatically.
- A two-layer token system (primitives for the palette, semantics for roles) keeps components stable through rebrands and theme changes.
- JavaScript integration via
setProperty()andgetPropertyValue()lets you coordinate CSS and JS without coupling them โ logic in JS, presentation in CSS. - Naming conventions become load-bearing quickly. Invest in role-based token naming at the start of a project, not halfway through.