How to Use CSS Custom Properties (Variables) the Right Way

- What CSS Custom Properties Actually Are
- How var() Works
- CSS Variables vs Sass Variables
- Building a Theme Switcher
- Custom Properties in calc()
- Reading and Writing Variables with JavaScript
- The @property Rule
- Common Mistakes and How to Avoid Them
- A Practical Naming Convention
- When to Reach for Custom Properties
What CSS Custom Properties Actually Are
CSS custom properties, better known as CSS variables, let you store a value once and reuse it anywhere in your stylesheet. You declare one with a double-hyphen name and read it back with the var() function:
:root {
--brand: #6c5ce7;
--space: 1rem;
}
.button {
background: var(--brand);
padding: var(--space);
}
The one detail that trips people up first: the -- prefix is required and the name is case-sensitive. --Brand and --brand are two different variables.
Unlike Sass variables, custom properties are live in the browser. They exist in the DOM at runtime, they inherit through the element tree, they respond to media queries, and JavaScript can read and change them on the fly. That single difference is what makes them worth learning even if your project already uses Sass.
Every evergreen browser has supported custom properties since 2017, so you can use them in production today without a fallback for the general case. The features to check individually are @property and style container queries, which arrived later.
How var() Works
The var() function takes the name of a custom property and, optionally, a fallback value:
.card {
color: var(--text-color, #333);
}
If --text-color is defined anywhere up the tree, its value is used. If it is not defined, #333 is used instead. The fallback is not an error handler for typos in your CSS values; it only fires when the variable itself is missing.
Custom Properties Inherit
This is the behavior that separates them from preprocessor variables. A custom property set on an element cascades down to its descendants, exactly like color or font-family:
.alert {
--accent: #e17055;
border-left: 4px solid var(--accent);
}
.alert .badge {
background: var(--accent); /* inherits #e17055 */
}
You can override the value at any level, and everything below that point picks up the new value. That is how a single component can be re-themed just by resetting one property on its root element.
Scoping to a Selector
Declaring variables on :root makes them global, but you rarely want everything global. Scope a variable to the component that owns it:
.pricing-card {
--card-bg: white;
--card-pad: 1.5rem;
background: var(--card-bg);
padding: var(--card-pad);
}
.pricing-card.featured {
--card-bg: #2d3436;
color: white;
}
The .featured modifier changes one variable, and the background follows. No duplicated background rule, no override specificity war.
CSS Variables vs Sass Variables
This is the comparison developers search for most, and the honest answer is that they solve different problems. Sass variables ($name) are resolved when your SCSS compiles to CSS and then vanish. CSS variables live on in the browser.
| Feature | Sass variable ($name) |
CSS variable (--name) |
|---|---|---|
| Exists at runtime | No, compiled away | Yes, lives in the DOM |
| Inherits through the DOM | No | Yes |
| Readable/writable from JS | No | Yes |
| Changes with media queries | No | Yes |
Usable in calc() |
Yes | Yes |
| Works without a build step | No | Yes |
| Can be used in a Sass loop/map | Yes | Not directly |
Use Sass variables for values the browser never needs to see: breakpoint numbers you feed into @media, a color map you iterate over to generate utility classes, or math done at build time. Use CSS variables for anything that changes at runtime: themes, component variants, values you tweak with JavaScript, or tokens that must respond to a media query.
In practice, modern codebases mix both. A common pattern is to define a Sass map of design tokens and emit them as custom properties so you get build-time convenience and runtime flexibility. If you are weighing where each tool fits in a larger system, our guide to Sass mixins vs functions covers the same build-time-versus-runtime line from the Sass side.
Building a Theme Switcher
The killer use case for custom properties is theming, because you change a handful of variables and the whole page re-paints. Define your tokens once, then override them under a data attribute or class:
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #666666;
}
:root[data-theme="dark"] {
--bg: #12141a;
--fg: #f4f4f5;
--muted: #9ca3af;
}
body {
background: var(--bg);
color: var(--fg);
}
Flipping the theme is a one-line JavaScript change:
document.documentElement.dataset.theme = "dark";
Because every component reads --bg and --fg instead of hard-coded hex values, nothing else has to know a theme exists. To respect the user's operating-system preference automatically, pair it with a media query:
@media (prefers-color-scheme: dark) {
:root {
--bg: #12141a;
--fg: #f4f4f5;
}
}
Custom Properties in calc()
Variables compose cleanly with calc(), which unlocks fluid, token-driven spacing and sizing:
:root {
--gap: 8px;
}
.stack > * + * {
margin-top: calc(var(--gap) * 2); /* 16px */
}
A word of caution about units. A custom property holds a raw token, not a typed value, so this silently fails:
:root { --size: 24; } /* just the number 24 */
.box { width: var(--size)px; } /* INVALID — do not do this */
The browser sees 24px glued together only if you do it through calc():
:root { --size: 24; }
.box { width: calc(var(--size) * 1px); } /* 24px, correct */
Storing the unit with the value (--size: 24px;) is usually cleaner and avoids the trap entirely. Reach for the calc() multiplication trick only when you genuinely need the bare number elsewhere, such as in an animation calculation. If you are unsure which unit belongs in your tokens, our CSS units guide breaks down px, em, rem, and the viewport units.
Reading and Writing Variables with JavaScript
Because custom properties are real DOM values, JavaScript can read and set them without touching individual style rules:
const root = document.documentElement;
// Read the computed value
const brand = getComputedStyle(root).getPropertyValue("--brand");
// Write a new value
root.style.setProperty("--brand", "#00b894");
getPropertyValue() returns the value as a string, often with leading whitespace, so trim it before comparing. Setting a property with setProperty() writes an inline style on that element, which will override stylesheet declarations of the same variable thanks to normal specificity rules. This is the foundation for interactive color pickers, live spacing controls, and cursor-following effects. Custom properties also animate, which our CSS animations tutorial puts to work.
The @property Rule
Plain custom properties are untyped strings, which means the browser cannot smoothly animate between two values of --brand because it does not know they are colors. The @property at-rule fixes that by registering a type:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.spinner {
transition: --angle 0.4s ease;
transform: rotate(var(--angle));
}
Now the browser knows --angle is an angle and can interpolate it during transitions and animations. @property is supported across current Chrome, Edge, Safari, and Firefox, but confirm your browser targets before relying on it, since older versions ignore it and fall back to the initial value.
A Note on Performance
Custom properties are cheap, but they are not free. When you change a variable that many elements read, the browser has to recompute styles for every element in that variable's scope. Changing a global token on :root that hundreds of nodes depend on is fine for a one-off theme switch, but avoid doing it inside a high-frequency loop such as a scroll or mouse-move handler on a large page. If you need per-frame updates, scope the variable to the smallest element that actually uses it so the browser has less work to recalculate.
Common Mistakes and How to Avoid Them
A few patterns cause most of the confusion around custom properties:
- Expecting a fallback to catch bad values.
var(--x, red)only usesredwhen--xis undefined. If--xis set togarbage, the fallback does not run; the property is simply invalid and the declaration is ignored. - Gluing units onto a variable.
var(--n)pxis invalid. Usecalc(var(--n) * 1px)or store the unit in the token. - Overusing
:root. Global tokens are fine for a palette, but scope component-specific variables to the component so they cannot leak or collide. - Forgetting the double hyphen.
var(-brand)andbackground: brandare silent no-ops. The--is mandatory. - Assuming Sass will interpolate it. Sass treats
var(--x)as a plain string, solighten(var(--x), 10%)will not work. Color functions need a real value, not a runtime reference.
A Practical Naming Convention
As a stylesheet grows, unstructured variable names become as hard to manage as the values they replaced. A light two-tier system keeps things readable:
:root {
/* Primitive tokens: raw values */
--color-purple-500: #6c5ce7;
--size-4: 1rem;
/* Semantic tokens: intent, built from primitives */
--color-action: var(--color-purple-500);
--space-inline: var(--size-4);
}
.button {
background: var(--color-action);
padding-inline: var(--space-inline);
}
Components reference the semantic layer, never the raw hex. When the brand color changes, you edit one primitive and every button, link, and badge updates. This is the same discipline design systems use, and it scales from a personal project to a large team without a rewrite.
When to Reach for Custom Properties
Use CSS custom properties whenever a value needs to change at runtime, be shared across unrelated components, respond to a media query, or be driven by JavaScript. Keep Sass variables for pure build-time work like breakpoint math and generating classes in a loop. Most real projects use both, and knowing which job each one does is the difference between a stylesheet you fight and one that maintains itself.