Quick Answer

CSS custom properties are declared with a double-dash name, read with var(), and inherit like colour does. Set them high in the tree, usually on :root, and read them low. They resolve at the element the declaration applies to, not where they were written. If a variable is missing or holds a value of the wrong type, the entire declaration becomes invalid and the property falls back to the inherited or initial value, not to an earlier declaration in the same rule.

Declaring and using a custom property

A custom property is any property whose name begins with two dashes. The browser does not try to understand the value when you declare it. It stores the raw tokens and only makes sense of them at the point where var() is used.

:root {
  --brand: #0b5fff;
  --space: 8px;
  --radius: 10px;
}

.button {
  background: var(--brand);
  border-radius: var(--radius);
  padding: var(--space) calc(var(--space) * 2);
}

Three details catch people out on day one.

  • Names are case-sensitive. --Brand and --brand are two separate properties. Normal CSS property names are not case-sensitive, so this feels inconsistent, and one stray capital fails silently with no warning anywhere.
  • var() only works inside a property value. You cannot write @media (min-width: var(--bp)), you cannot use it in a selector, and you cannot build a property name out of it. Declaring --side: margin-left and then writing var(--side): 10px does nothing at all.
  • The value is not checked where you write it. --space: 8pxx; is a completely legal declaration. The mistake only surfaces later, in whichever rule tries to use it.

That last point is the real difference from Sass or Less variables. Those are a compile-time find and replace: by the time the browser sees the file, they no longer exist, so they cannot respond to a media query, a class change or a click. Custom properties are actual CSS properties. They live in the cascade, they inherit, and they can be changed at runtime.

One arithmetic trap worth knowing early. calc() works on whatever tokens the variable holds, so --space: 8 with calc(var(--space) * 2) gives you the unitless number 16, which is not a valid padding. Either store the unit in the variable, or store a bare number deliberately and multiply by a unit: calc(var(--space) * 1px). Both are fine, but mixing the two conventions in one codebase guarantees confusion.

Scoping is just inheritance

There is no special scoping mechanism for custom properties. They inherit exactly the way color inherits: a value set on an element is visible to that element and to everything inside it, and to nothing above or beside it.

:root      { --gap: 16px; }
.sidebar   { --gap: 4px; }

.list { gap: var(--gap); }

A .list in the page body gets 16px. The same .list inside .sidebar gets 4px, without any extra selector, because the closest ancestor that defines --gap wins. This is why :root is the usual home for global tokens. It is the html element, so everything inherits from it.

The mental model that matters: a var() is resolved against the element the declaration applies to, not the element the rule was written near. If .list is inside .sidebar, the lookup for --gap starts at that .list element and walks up. Where you typed the rule is irrelevant.

This gives you a clean way to build component variants. Declare the knobs on the component, then override just the knobs.

.card {
  --card-pad: 16px;
  --card-bg: #ffffff;

  padding: var(--card-pad);
  background: var(--card-bg);
}

.card.is-compact  { --card-pad: 8px; }
.card.is-highlight { --card-bg: #fff7e6; }

The variant classes never touch padding or background directly, so specificity stays flat and the component keeps one place where each property is actually applied. Treat the custom properties as the public API of the component and the real declarations as private.

The failure mode is setting a variable too low. If a button sets --brand on itself and a sibling tooltip reads --brand, the tooltip sees nothing, because inheritance only goes downwards. When a variable mysteriously has no effect, check which element it is actually declared on with devtools rather than assuming it is global.

Fallbacks and the invalid value trap

var() takes an optional second argument used when the property is not defined.

.button {
  color: var(--btn-text, #ffffff);
  font-family: var(--font, "Noto Sans", system-ui, sans-serif);
}

Everything after the first comma is the fallback, commas included. That is why the font stack above works as one fallback rather than being read as three arguments.

Now the part that surprises almost everyone. This is the classic defensive pattern from ordinary CSS, where an unsupported value is dropped and the previous declaration survives:

.button {
  color: #333333;
  color: var(--accent);
}

If --accent is undefined or holds something that is not a colour, .button does not fall back to #333333. It ends up with whatever colour it inherits from its parent, which is usually black.

The reason is a rule called invalid at computed-value time. The browser cannot know whether var(--accent) is valid while parsing the stylesheet, because the variable can change later. So it accepts the declaration, throws away the earlier color: #333333, and only discovers the problem when computing styles. At that point it is too late to go back, so the property is treated as unset: inherited value for inheritable properties like color, initial value for everything else. A typo such as --accent: 16px used as a colour produces exactly the same result.

Two practical consequences. First, always give var() a fallback when the variable might be missing, because the fallback is checked properly. Second, do not use the two-declaration trick as a safety net with variables. It is not one.

An empty fallback is legal and occasionally useful: var(--extra, ) resolves to nothing, which lets you optionally inject a value into a longer declaration without breaking it.

Using variables for theming

Theming is the case where custom properties genuinely change how you write CSS. The whole point is that a theme should be one small block of declarations, not a duplicate of every rule in the file.

The single most important habit is naming variables by role, not by appearance. --white is a useless name the moment it becomes dark grey in a dark theme. --surface survives.

:root {
  --surface: #ffffff;
  --surface-2: #f4f6f8;
  --text: #14161a;
  --text-muted: #5b6472;
  --border: #e2e6ea;
  --brand: #0b5fff;
}

[data-theme="dark"] {
  --surface: #14161a;
  --surface-2: #1d2027;
  --text: #e8eaed;
  --text-muted: #9aa3af;
  --border: #2a2f38;
  --brand: #5b8cff;
}

body  { background: var(--surface); color: var(--text); }
.card { background: var(--surface-2); border: 1px solid var(--border); }

Flipping the theme is now one attribute on html. Because the variables are declared on an ancestor and every component reads them by inheritance, no component rule needs to know a theme exists.

Two habits keep this maintainable as the codebase grows. Build a second layer of semantic aliases on top of your raw palette, so --btn-bg: var(--brand) sits between the component and the palette and can be repointed for one component without touching the rest. And keep spacing, radii and font sizes in the same system, because a design that only tokenises colour ends up with forty hardcoded pixel values scattered around anyway.

Custom properties cannot be transitioned or animated by default, because the browser treats their value as an untyped token stream and has no idea how to interpolate it. Registering a property with @property gives it a type and makes it animatable:

@property --shade {
  syntax: "<color>";
  inherits: false;
  initial-value: #0b5fff;
}

Browser support for @property is good in current browsers but not universal in older ones, so check before you depend on it for anything load-bearing.

Reading and writing them from JavaScript

This is where custom properties beat every preprocessor. They are readable and writable at runtime with two ordinary DOM calls.

const root = document.documentElement;

// read the resolved value
const brand = getComputedStyle(root).getPropertyValue("--brand").trim();

// write it (this sets an inline style on <html>)
root.style.setProperty("--brand", "#e11d48");

// remove the override and fall back to the stylesheet
root.style.removeProperty("--brand");

Note the .trim(). Browsers used to hand back the value with the whitespace you wrote after the colon still attached, so --brand: #0b5fff came back as " #0b5fff" and a naive equality check against "#0b5fff" failed. Current browsers trim it, but this has changed over time and is not something worth betting a comparison on, so keep the .trim(). The result is also always a string, never a number, so arithmetic needs parseFloat.

A second trap: element.style.getPropertyValue("--brand") reads only the inline style attribute. If the variable came from a stylesheet you get an empty string. Use getComputedStyle when you want the resolved value and element.style only when you want to check your own override.

Because setProperty writes an inline style, and inline styles inherit, setting one variable on html repaints an entire interface. That single fact powers theme switchers, accent-colour pickers and density toggles without touching a single component.

A useful pattern is pushing live values from JavaScript into CSS and letting CSS do the layout maths:

const card = document.querySelector(".card");

card.addEventListener("pointermove", (e) => {
  const r = card.getBoundingClientRect();
  card.style.setProperty("--mx", (e.clientX - r.left) + "px");
  card.style.setProperty("--my", (e.clientY - r.top) + "px");
});
.card {
  background: radial-gradient(
    240px circle at var(--mx, 50%) var(--my, 50%),
    rgba(11, 95, 255, 0.18),
    transparent 70%
  );
}

JavaScript supplies two numbers, CSS owns the appearance. Writing an inline style on every pointer move does force a style recalculation, so on a busy page throttle it inside requestAnimationFrame rather than firing on every event.

Frequently Asked Questions

What is the difference between CSS variables and Sass variables? Sass variables are resolved at compile time and do not exist in the file the browser downloads, so they cannot change after the page loads. CSS custom properties are real properties that live in the cascade, inherit down the tree, and can be changed by a media query, a class swap or JavaScript. If you need a value to react to anything at runtime, it has to be a custom property.
Why does my var() fallback in the previous declaration not work? Because a declaration containing var() is accepted at parse time and only found to be broken when styles are computed. By then the earlier declaration in the same rule has already been discarded. The property becomes invalid at computed-value time and takes the inherited value for inheritable properties or the initial value otherwise. Put the fallback inside var() itself, as the second argument, where it is actually checked.
Can I use a CSS variable inside a media query? No. var() is only valid in a property value, so a breakpoint cannot be stored in a custom property and read back in a media query condition. Container queries have the same restriction. Keep breakpoints in a preprocessor variable or a build-time constant if you need them in one place, and use custom properties for the values inside the media query blocks.
Do custom properties slow a page down? For normal use the cost is not something you would notice. The one thing to watch is changing a variable declared on the root element while many elements read it, since every element that depends on it has to recompute its styles. That is fine for a theme switch that happens once, and worth throttling if you are writing a variable on every pointer or scroll event.
How do I check whether a browser supports custom properties? Use a feature query: @supports (--test: 0) { ... }. In practice every browser that receives meaningful traffic today supports the basic var() syntax, so this is rarely needed. The features worth guarding are newer additions such as @property registration, and the most reliable way to do that is to give the property a sensible static value first, so the page still looks right if the registration is ignored.