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 variables โ€” resolved at compile time, static
// 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 Custom Properties โ€” evaluated at runtime, dynamic
/* 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') */
๐Ÿ“Œ Key Point
The right answer isn't Sass variables or CSS Custom Properties โ€” it's both. A common pattern in production is using Sass to generate CSS Custom Properties, getting the best of compile-time tooling and runtime flexibility.

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.

Component variants via scoped variables
/* 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.

โ–ถ Live Demo โ€” Card variants via scoped CSS variables
Default

Standard card

Danger

Warning card

Info

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.

Dark mode: only the variables change, nothing else
/* 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);
}
๐Ÿ’ก Pro Tip
The real payoff comes when adding new components: because they use the same variables, they automatically get dark mode support. You don't need to remember to add dark mode overrides. The system handles it.

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.

Manual theme toggle with data-theme
/* 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');
});
โ–ถ Live Demo โ€” Dark mode toggle
Theme Preview
CSS Custom Properties

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.

โŒ Anti-pattern: naming by value
: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. */
โœ… Better pattern: two-layer token system
: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.

Reading and writing CSS variables from JavaScript
// 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.

โ–ถ Live Demo โ€” Realtime slider controlling CSS variables
8px
20px
1.00
CSS variables updating in real time
โš ๏ธ Watch Out
getPropertyValue() returns the raw string value including any surrounding whitespace. Always call .trim() before using the result, especially if you're parsing it as a number.

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() and getPropertyValue() 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.