CSS custom properties — commonly called CSS variables — are the single most powerful modern CSS feature for building maintainable, themeable stylesheets. Unlike Sass/LESS variables that compile away, CSS custom properties are live in the browser, can be scoped to any element, respond to media queries, and can be changed dynamically with JavaScript.
That "live" quality is what changes the game. With a preprocessor variable, the value is baked in at build time; changing a theme means shipping a second stylesheet. With custom properties, a dark mode is one attribute flip, a component can be re-skinned by its parent, and JavaScript can adjust a single value that instantly cascades through hundreds of rules. Browser support has been universal since 2017, so there is no longer any reason to hold back. This guide walks through the syntax, the scoping model, and the practical patterns — theming, design tokens, and JavaScript integration — that make custom properties the backbone of a modern stylesheet.
Basic Syntax
A custom property is any declaration whose name starts with two dashes. You read it back with the var() function, which optionally takes a fallback as its second argument. Properties are usually declared on :root (the <html> element) so every element on the page inherits them, but they can be declared on any selector. Note that names are case-sensitive — --Color and --color are two different properties — and unlike normal CSS keywords, that distinction will silently bite you.
/* Define on :root for global scope */
:root {
--color-primary: #6366f1;
--color-bg: #0f172a;
--color-text: #e2e8f0;
--radius: 12px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
--font-sans: 'Inter', system-ui, sans-serif;
}
/* Use with var() */
.card {
background: var(--color-bg);
color: var(--color-text);
border-radius: var(--radius);
box-shadow: var(--shadow);
font-family: var(--font-sans);
}
/* Fallback value */
.card {
color: var(--color-text, #ffffff);
}
The fallback matters more than it looks. If --color-text is undefined (or holds an invalid value for the property it's used in), the declaration computes to unset — which for an inherited property like color means "inherit whatever the parent has," often producing a confusing but not obviously broken page. A sensible fallback turns that silent failure into a predictable default. Fallbacks are also useful for components distributed as libraries: var(--btn-radius, 8px) lets consumers customize the button while guaranteeing it renders correctly out of the box.
Scoping & Inheritance
Custom properties follow the CSS cascade and are inherited by child elements. This makes them perfect for component-level theming.
This is the pattern that separates people who merely use variables from people who design with them. The button component below references --btn-bg without knowing or caring where the value comes from. By default it resolves from :root; inside .danger-zone, the closer declaration wins and every button in that section turns red — with zero changes to the button's own CSS and no extra class on each button. Contrast that with the traditional approach of writing a .danger-zone .btn override, which couples the section to the component and grows combinatorially as you add variants.
/* Global defaults */
:root {
--btn-bg: #6366f1;
--btn-text: #ffffff;
--btn-radius: 8px;
}
/* Override for a specific section */
.danger-zone {
--btn-bg: #ef4444;
--btn-text: #ffffff;
}
/* The button component uses the same variable names */
.btn {
background: var(--btn-bg);
color: var(--btn-text);
border-radius: var(--btn-radius);
}
/* Buttons inside .danger-zone automatically turn red */
Dark Mode with Custom Properties
Dark mode is the canonical use case for custom properties, and the pattern below is the one used by most production sites. The idea: never reference raw colors in component CSS — only semantic variables like --bg and --text. Then switching themes is a matter of redefining those variables in one place. The example handles all three states users expect: an explicit light theme, an explicit dark theme via a data-theme attribute, and "follow my operating system" via prefers-color-scheme.
/* Light theme (default) */
:root {
--bg: #ffffff;
--bg-secondary: #f8fafc;
--text: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--accent: #6366f1;
}
/* Dark theme */
[data-theme="dark"] {
--bg: #0f172a;
--bg-secondary: #1e293b;
--text: #e2e8f0;
--text-muted: #94a3b8;
--border: rgba(255, 255, 255, 0.1);
--accent: #818cf8;
}
/* System preference detection */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f172a;
--bg-secondary: #1e293b;
--text: #e2e8f0;
--text-muted: #94a3b8;
--border: rgba(255, 255, 255, 0.1);
--accent: #818cf8;
}
}
The :root:not([data-theme="light"]) selector is the subtle part: it applies dark values when the OS prefers dark unless the user has explicitly chosen light on your site. User choice should always beat system preference. Two more practical tips: store the explicit choice in localStorage and apply it with a tiny inline script in the <head> before the stylesheet loads, otherwise returning visitors see a flash of the wrong theme; and don't just invert lightness — dark themes usually need slightly desaturated, lighter accent colors (note #818cf8 versus #6366f1 above) to maintain contrast against dark backgrounds.
JavaScript Integration
Because custom properties live in the DOM, JavaScript can read and write them like any other style — this is something Sass fundamentally cannot do. Reading requires getComputedStyle (the resolved value, after the cascade) and a trim(), since the raw value preserves whitespace. Writing with setProperty on document.documentElement updates every rule that references the variable in a single operation, which makes it perfect for user-configurable accent colors, live preview panels, or feeding scroll/pointer positions into CSS animations.
// Read a CSS variable
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
// Set a CSS variable
document.documentElement.style.setProperty('--accent', '#10b981');
// Theme toggle
function toggleTheme() {
const current = document.documentElement.dataset.theme;
document.documentElement.dataset.theme = current === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', document.documentElement.dataset.theme);
}
Design Tokens System
Design tokens are the named, reusable values — spacing, type sizes, radii, shadows, durations — that make an interface feel coherent, and custom properties are their natural home in CSS. The payoff of a scale like the one below is constraint: when every gap on the page is one of seven spacing values, layouts look intentional and refactoring is trivial. Numeric suffixes that map to a base unit (--space-4 = 1rem = 16px) are easier to remember than t-shirt sizes once the scale grows.
:root {
/* Spacing scale */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
/* Font sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
/* Responsive font sizes */
--text-hero: clamp(2rem, 5vw, 3.5rem);
}
The clamp() line at the end is worth stealing: it gives you fluid, responsive typography in a single declaration — a minimum of 2rem, a preferred size tied to viewport width, and a 3.5rem ceiling — with no media queries at all. Because custom properties can be redefined inside media queries, you can also shift the entire spacing scale at a breakpoint (say, tightening --space-8 on mobile) and every component that uses the token adapts automatically.
Common Pitfalls
- Custom properties are not preprocessor variables. You cannot use them in a media query condition (
@media (min-width: var(--bp))does not work) or as part of a selector. They hold values, not arbitrary CSS text. - Invalid at computed-value time. If a variable resolves to something nonsensical for the property (
width: var(--color-primary)), the browser doesn't fall back to earlier declarations in the cascade the way it would with a normal typo — the property becomesunset. This makes mistakes quieter and harder to spot in DevTools. - Units don't concatenate.
--n: 20; width: var(--n)px;fails. Either store the unit in the variable or multiply insidecalc():calc(var(--n) * 1px). - Overusing inline
stylewrites from JS. Setting dozens of variables per frame from a scroll handler can cause large style recalculations. Batch updates and prefer changing one high-level variable over many low-level ones.
Best Practices
- Use semantic names —
--color-primaryover--blue-500. This lets you change the actual color without renaming. - Define all design tokens in
:rootfor global consistency. - Scope component-specific variables to the component selector.
- Always provide fallback values for critical properties:
var(--color, #000). - Use custom properties for anything you might want to change — colors, spacing, fonts, shadows, border-radius, animation durations.
Wrapping Up
Start small: pick the hard-coded colors in your current project, hoist them into semantic variables on :root, and replace every literal with var(). That single refactor makes a dark theme, a brand re-skin, or a high-contrast mode achievable in an afternoon instead of a rewrite. From there, layer in a spacing and type scale, scope component variables where it helps, and wire up a theme toggle with the JavaScript pattern above. Custom properties reward exactly the discipline they enable — a stylesheet where every meaningful value has one name and one home.