CSS custom properties — commonly called CSS variables — are one of the most impactful features added to CSS in the last decade. They bring real variable behavior to stylesheets: declare a value once, use it everywhere, and change everything from one place. They are the foundation of modern design tokens, dark mode implementation, component theming, and runtime UI customization.
This guide covers everything from the basic syntax to advanced patterns: scope and inheritance, fallback values, manipulating variables with JavaScript, building a complete dark/light mode system, and integrating variables into a design token architecture. Also see our CSS custom properties deep dive for browser compatibility and the @property API.
Syntax: Defining and Using CSS Variables
Custom properties start with two dashes (--) and can hold any CSS value — a color, a size, a string, even a full value fragment. The var() function retrieves them:
/* Define on :root for global scope */
:root {
--color-primary: #7c6fff;
--color-text: #f0f0ff;
--spacing-base: 16px;
--radius: 8px;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
/* Use anywhere */
.button {
background: var(--color-primary);
color: var(--color-text);
padding: var(--spacing-base);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
Fallback Values
The var() function accepts a second argument as a fallback for when the variable is not defined:
.component {
/* If --brand-color is not defined, use #7c6fff */
color: var(--brand-color, #7c6fff);
/* Nested fallback — use --secondary if --primary is not set, else red */
color: var(--primary, var(--secondary, red));
}
Scope and Inheritance
CSS custom properties follow the normal CSS cascade and inheritance rules. A variable defined on a parent element is inherited by all its children. You can redefine a variable at a lower scope to override it for a subtree — this is what makes component-level theming possible:
/* Global defaults */
:root {
--accent: #7c6fff;
--bg-card: #13131a;
}
/* Override for a specific component */
.card-danger {
--accent: #ef4444;
--bg-card: #1f0f0f;
}
/* This button uses --accent — it'll be red inside .card-danger */
.card-danger .button {
background: var(--accent);
}
This scoping mechanism is more powerful than SCSS variables, which are resolved at compile time and can't change based on DOM context.
Dark Mode with CSS Variables
CSS custom properties are the cleanest way to implement dark/light mode. Define your semantic color tokens on :root, then redefine them inside a media query or a class:
/* Light mode defaults (or dark mode — pick one as your base) */
:root {
--bg: #f5f5f8;
--surface: #ffffff;
--text: #1a1a2e;
--muted: #6a6a8a;
--accent: #6c5ce7;
--border: #d8d8e4;
}
/* Dark mode via media query (OS preference) */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0f;
--surface: #13131a;
--text: #f0f0ff;
--muted: #7070a0;
--accent: #7c6fff;
--border: #2a2a3d;
}
}
/* Dark mode via class (user toggle) */
:root.dark {
--bg: #0a0a0f;
--surface: #13131a;
--text: #f0f0ff;
--muted: #7070a0;
--accent: #7c6fff;
--border: #2a2a3d;
}
Using the class approach (alongside the media query) lets you give users a manual toggle that overrides their OS preference — exactly what CSSTools.io does with the theme button in the sidebar.
Naming convention: Use semantic names like --color-text, --color-surface, and --color-accent rather than raw color names like --purple or --dark-gray. Semantic names describe intent — they remain meaningful when the palette changes, and they work for both dark and light modes.
Design Tokens Architecture
Design tokens are named values that store design decisions — colors, spacing, radii, shadows, motion durations. CSS custom properties are the ideal runtime representation of tokens. The recommended structure uses two layers:
Layer 1: Primitive Tokens
Raw values with no semantic meaning — the full color palette, the spacing scale, the full type scale:
:root {
/* Color primitives */
--purple-100: #ede9ff;
--purple-400: #7c6fff;
--purple-700: #4d3fc0;
/* Spacing scale */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
}
Layer 2: Semantic Tokens
Tokens that reference primitives and carry semantic intent. These are what your components actually use:
:root {
--color-bg: var(--gray-50);
--color-surface: var(--gray-0);
--color-text: var(--gray-900);
--color-text-muted: var(--gray-500);
--color-accent: var(--purple-400);
--color-border: var(--gray-200);
--component-radius: var(--radius-md);
--component-padding-x: var(--space-4);
--component-padding-y: var(--space-3);
}
/* Theme override just changes semantic tokens, not primitives */
:root.dark {
--color-bg: var(--gray-950);
--color-surface: var(--gray-900);
--color-text: var(--gray-50);
--color-text-muted: var(--gray-400);
--color-border: var(--gray-800);
}
Generate a color palette for your design tokens
Create shades, tints, and harmonies from any base color — copy as CSS variables instantly.
Using CSS Variables with JavaScript
One of the most powerful aspects of CSS custom properties is that JavaScript can read and write them at runtime, enabling dynamic theming that would be impossible with SCSS variables (which are compiled away):
Reading a CSS Variable
const root = document.documentElement;
const accent = getComputedStyle(root).getPropertyValue('--accent').trim();
console.log(accent); // "#7c6fff"
Setting a CSS Variable
// Change the accent color for the whole page
document.documentElement.style.setProperty('--accent', '#22c55e');
// Change a variable on a specific element
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#1a1a2e');
card.style.setProperty('--card-radius', '20px');
Theme Switcher Pattern
function setTheme(theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.add('light');
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}
// Restore on page load (before paint to avoid flash)
const saved = localStorage.getItem('theme') || 'light';
setTheme(saved);
CSS Variables for Component Theming
CSS custom properties make component APIs possible in pure CSS. A button component can expose its own custom properties that consumers override — just like React props, but in CSS:
/* Button defaults — defined within the component */
.btn {
--btn-bg: var(--accent);
--btn-color: #fff;
--btn-radius: 8px;
--btn-padding: 10px 20px;
background: var(--btn-bg);
color: var(--btn-color);
border-radius: var(--btn-radius);
padding: var(--btn-padding);
border: none;
cursor: pointer;
font-family: inherit;
}
/* Consumer overrides — no specificity battles */
.btn-outline {
--btn-bg: transparent;
--btn-color: var(--accent);
border: 2px solid var(--accent);
}
.btn-danger {
--btn-bg: #ef4444;
}
.btn-large {
--btn-padding: 14px 28px;
--btn-radius: 12px;
}
CSS Variables vs SCSS Variables
SCSS variables ($variable) are resolved at compile time and produce static CSS. CSS custom properties exist at runtime and can change. The practical differences:
- Dynamic values — CSS variables can change based on DOM state, user input, or JavaScript. SCSS variables cannot.
- Scope — CSS variables follow DOM inheritance. SCSS variables follow file scope.
- DevTools — CSS variables are visible and editable in browser DevTools. SCSS variables are gone by the time the browser sees the file.
- Dark mode — CSS variables can implement dark mode natively. SCSS requires compiling separate stylesheets or duplicating selectors.
- Best practice — use SCSS variables for build-time constants (breakpoints, z-index scales) and CSS custom properties for runtime values (colors, spacing, component tokens).
Frequently Asked Questions
Are CSS variables the same as SCSS variables?
$var) are resolved at compile time and disappear from the CSS output. CSS custom properties (--var) are real browser features that exist at runtime, can be changed by JavaScript, cascade through the DOM, and are visible in DevTools. SCSS is a build tool; CSS custom properties are a browser standard.Can CSS variables be used inside media queries?
@media (max-width: 768px) { :root { --font-size-base: 14px; } }Do CSS variables work in all browsers?
var() and uses the static value above it).Can I use CSS variables inside calc()?
calc(var(--spacing-base) * 2), calc(100% - var(--sidebar-width)), calc(var(--font-size) * var(--line-height)) — all valid. The variable must resolve to a number or length for the math to work.What happens if a CSS variable is invalid or undefined?
color becomes black, background becomes transparent). If the variable is defined but its value is invalid for the property (e.g. a color variable being used as a font-size), the property is treated as invalid and falls back to the inherited value or initial value.Ready to put these techniques into practice? Use our Color Palette Generator to create a palette you can paste directly as CSS variables, or read the CSS custom properties deep dive for the @property typed custom properties API.