The Basic Syntax
Custom properties are defined with a double-dash prefix and accessed with the var() function:
:root {
--color-primary: #7c5cfc;
--spacing-base: 1rem;
--border-radius: 12px;
}
.button {
background: var(--color-primary);
padding: var(--spacing-base);
border-radius: var(--border-radius);
}
Scope and Inheritance
Unlike Sass variables, CSS custom properties are live in the DOM and follow CSS inheritance. A variable defined on a parent is available to all its descendants. This makes component-level scoping natural:
:root {
--card-bg: rgba(255,255,255,0.03); /* global default */
}
.card--featured {
--card-bg: rgba(124,92,252,0.08); /* scoped override */
}
.card {
background: var(--card-bg); /* uses nearest ancestor value */
}
This is fundamentally different from Sass variables, which are compile-time substitutions. CSS custom properties resolve at runtime, meaning JavaScript can update them and the UI responds immediately.
Fallback Values
The var() function accepts a fallback as the second argument — used when the variable is undefined or invalid:
.component {
color: var(--text-color, #e2e8f0);
/* Uses --text-color if defined, #e2e8f0 otherwise */
/* Fallbacks can chain: */
font-size: var(--font-size-lg, var(--font-size-base, 1rem));
}
Dark Mode with CSS Variables
Custom properties are the cleanest approach to dark mode — define your semantic color tokens once at the root, then swap their values based on color scheme preference:
:root {
--bg-surface: #ffffff;
--bg-elevated: #f8fafc;
--text-primary: #1e293b;
--text-muted: #64748b;
--border-color: #e2e8f0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-surface: #050508;
--bg-elevated: #0f0f14;
--text-primary: #e2e8f0;
--text-muted: #94a3b8;
--border-color: rgba(255,255,255,0.08);
}
}
/* Components use tokens, never raw colors */
.card {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
Every component automatically adapts to dark mode because they reference semantic tokens, not hardcoded color values. This is the key advantage over hardcoding colors directly.
JavaScript Integration
CSS custom properties can be read and written from JavaScript at runtime — this is what makes them fundamentally different from preprocessor variables:
// Read a CSS variable
const root = document.documentElement;
const primary = getComputedStyle(root).getPropertyValue('--color-primary');
// Write a CSS variable (triggers re-render)
root.style.setProperty('--color-primary', '#ec4899');
// Component-level override
const card = document.querySelector('.card');
card.style.setProperty('--card-accent', '#0ea5e9');
Design Token System
The real power of CSS custom properties is as a design token system. Define all your design decisions as variables in one place:
:root {
/* ─── Color Palette ─────────────────────── */
--hue-primary: 257;
--color-primary: hsl(var(--hue-primary) 95% 66%);
--color-primary-dim: hsl(var(--hue-primary) 95% 66% / 0.15);
/* ─── Spacing (8px grid) ───────────────── */
--space-1: 0.5rem; /* 8px */
--space-2: 1rem; /* 16px */
--space-3: 1.5rem; /* 24px */
--space-4: 2rem; /* 32px */
--space-6: 3rem; /* 48px */
/* ─── Typography ───────────────────────── */
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--line-height-body: 1.6;
--line-height-heading: 1.2;
/* ─── Borders ───────────────────────────── */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 9999px;
/* ─── Shadows ───────────────────────────── */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2), 0 4px 8px rgba(0,0,0,0.15);
--shadow-md: 0 4px 8px rgba(0,0,0,0.25), 0 16px 32px rgba(0,0,0,0.2);
}
CSS Variables vs Sass Variables
Both solve the "don't repeat yourself" problem in CSS, but they work differently and solve different problems:
- CSS custom properties: Live in the browser, can be changed at runtime by JavaScript, follow DOM inheritance, work without a build step
- Sass variables: Compiled away at build time, not accessible in the browser, don't respond to runtime changes
- When to use CSS variables: Theming, dark mode, component variants, anything that changes at runtime
- When to use Sass variables: Build-time constants like breakpoints, z-index scales, or values that feed into Sass functions
- In 2026: Use CSS custom properties as your primary variable system; only reach for Sass variables for Sass-specific functionality
Common Mistakes
CSS variables have a few non-obvious gotchas:
- Units in variables: You cannot store just a number and append a unit later.
--size: 16; width: var(--size)px;does NOT work. Store the full value including unit:--size: 16px - Invalid values silently fail: If a variable value is invalid for a given property, the browser uses the property's initial value — not the fallback in
var(). The fallback only applies when the variable is undefined. - Not supported in media queries: You cannot use
var()inside@mediabreakpoint conditions. Use CSS container queries or define breakpoints in JavaScript. - Forgetting :root scope: Variables defined inside a selector are only available to that element and its descendants. Always define global tokens on
:root. - Circular references: CSS variables that reference each other in a loop both become invalid. The browser detects this and treats them as unset.
Frequently Asked Questions
- Are CSS variables supported everywhere? Yes — browser support is 97%+ globally as of 2026, including all modern browsers. IE11 is the only notable exception, which has market share well under 0.5%.
- Can I use CSS variables in SVG? Yes, CSS custom properties work inside inline SVG. This is particularly useful for theming SVG icons — define
fill: var(--icon-color)in the SVG and control the color from CSS. - Do CSS variables work in animations? Yes, but with a caveat: browsers cannot interpolate between two variable values directly. You need to use
@propertyto register the variable with a type (like<color>or<length>) to enable smooth animation. - Can I use calc() with CSS variables? Yes, this is one of the most powerful combinations:
width: calc(var(--column-width) * 2 + var(--gap)). This unlocks complex layout math that updates dynamically.