TH The Sass Way
CSS Techniques

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

How to Use CSS Custom Properties (Variables) the Right Way
tldrCSS variables, or custom properties, store a reusable value declared with a double-hyphen name like --brand and read with the var() function. Unlike Sass variables, they live in the browser at runtime: they inherit through the DOM, respond to media queries, and can be read or changed with JavaScript. Every evergreen browser has supported them since 2017.

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:

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.

FAQ

What is the difference between CSS variables and Sass variables?

Sass variables ($name) are resolved when SCSS compiles to CSS and then disappear, so they are pure build-time values. CSS variables (--name) live on in the browser: they inherit through the DOM, react to media queries, and can be read or written with JavaScript. Use Sass variables for build-time math and loops, and CSS variables for anything that changes at runtime, like themes.

How do you use var() in CSS?

Call var() with a custom property name to read its value: color: var(--text). You can add a fallback as a second argument, like var(--text, #333), which is used only when --text is undefined. The fallback does not catch invalid values; if the variable is set to something the property cannot parse, the whole declaration is simply ignored.

Are CSS custom properties supported in all browsers?

Yes for the core feature. Chrome, Edge, Firefox, and Safari have all supported CSS custom properties and var() since 2017, so no fallback is needed for typical use. Newer additions layered on top, such as the @property at-rule for typed, animatable variables, arrived later, so check current browser support before relying on those specific features.

Can you change a CSS variable with JavaScript?

Yes. Read a value with getComputedStyle(element).getPropertyValue('--name'), which returns a string you should trim. Set a value with element.style.setProperty('--name', value), which writes an inline style that overrides stylesheet declarations of the same variable. This makes custom properties ideal for interactive color pickers, live spacing controls, and theme toggles.

Why is my CSS variable not working?

The most common causes are a missing double hyphen (the -- prefix is required and names are case-sensitive), gluing a unit directly onto var() like var(--n)px which is invalid, or referencing a variable that was never defined in the current scope. Also remember that a fallback only fires when the variable is undefined, not when its value is unparseable.

How do you build a dark mode theme with CSS variables?

Define your color tokens on :root, then override those same variables under a selector like :root[data-theme='dark'] or inside a prefers-color-scheme: dark media query. Because every component reads the variables instead of hard-coded hex values, flipping one attribute with JavaScript re-themes the entire page without touching any component styles.