CSS CSS Variables (Custom Properties)

CSS custom properties—commonly called CSS variables—let you define a reusable value once and reference it anywhere in your stylesheet with the var() function. Instead of repeating the same color code, spacing value, or font stack across dozens of rules, you store it in a variable and every reference stays in sync automatically. Unlike variables in a preprocessor like Sass, custom properties are a native part of the CSS cascade: the browser can recalculate them live, in response to media queries, pseudo-classes like :hover, or JavaScript, without recompiling anything.

Overview / How it works

A custom property is declared like any other CSS declaration, except its name starts with two hyphens: --main-color: #3366ff;. It must be declared inside a rule (attached to a selector), because custom properties are scoped to the element(s) that selector matches, and they cascade and inherit down the DOM tree exactly like normal inherited properties such as color or font-family.

Most projects declare their global variables on :root, a pseudo-class that matches the document’s root element (the <html> element) but has higher specificity than an html type selector. Because :root sits at the very top of the document tree, any variable declared there is inherited by every element on the page, unless something further down overrides it.

Custom properties are read with var(--name), or var(--name, fallback) if you want a value to use when the variable is missing. Crucially, the browser does not validate the contents of a custom property when it is declared—--main-color could hold a color, a number, a whole shorthand value, or even an unbalanced string of tokens. Validation only happens when the variable is substituted into a real property via var(). If the resulting value is not valid for that property, the declaration is treated as invalid at computed-value time, and the property falls back to its inherited or initial value—not to any fallback you wrote inside var(), since that fallback only applies when the variable itself is unset.

Because custom properties live in the cascade, normal cascade rules—origin, specificity, and source order—decide which declaration of --name “wins” for a given element, and inheritance carries the winning value down to any descendant that doesn’t redeclare it. This is what makes variables so useful for theming: redeclare --button-bg inside a class like .button--danger, and every property that reads var(--button-bg) inside that scope automatically picks up the new value, with zero duplication.

Syntax

selector {
  --custom-property-name: value;
}

another-selector {
  property: var(--custom-property-name, fallback-value);
}
  • –custom-property-name — the variable’s name. Must start with two hyphens, is case-sensitive (--Main and --main are different variables), and may contain letters, digits, hyphens, and underscores.
  • value — any sequence of CSS tokens. Not validated at declaration time; can be a color, length, string, list, or even a partial value meant to be combined with other text.
  • var(–name) — substitutes the current value of --name at the point of use, following normal cascade and inheritance rules for the element it’s used on.
  • var(–name, fallback-value) — the optional second argument is used only if --name is not defined (or inherited) on that element at all. It is not used when the variable is defined but produces an invalid value for the property.
Feature CSS custom property Preprocessor variable (e.g. Sass)
When resolved At runtime, in the browser At build/compile time
Responds to media queries / states Yes No (fixed once compiled)
Readable/writable from JavaScript Yes (getComputedStyle, style.setProperty) No (doesn’t exist after compilation)
Inherits through the DOM Yes, like normal CSS properties N/A (textual substitution only)

Examples

Example 1: A basic global variable

:root {
  --main-color: #3366ff;
  --spacing: 16px;
}

.card {
  color: var(--main-color);
  padding: var(--spacing);
  border: 1px solid var(--main-color);
}

Result: Any element with class card gets blue (#3366ff) text and a matching 1px blue border, with 16px of padding on all sides. If you later change only the two values inside :root, every element referencing var(--main-color) or var(--spacing) across the whole stylesheet updates together.

This is the simplest and most common use: centralize a design decision once, in one place, and reuse it everywhere instead of retyping the literal value in multiple rules.

Example 2: Scoped overrides for theming

:root {
  --button-bg: #222222;
  --button-fg: #ffffff;
}

.button {
  background-color: var(--button-bg);
  color: var(--button-fg);
  padding: 0.5em 1em;
  border-radius: 4px;
  border: none;
}

.button--danger {
  --button-bg: #cc3333;
}

Result: A plain <button class=”button”> renders with a dark gray background and white text. A <button class=”button button–danger”> renders with a red background instead, because .button--danger redeclares --button-bg for that element (and any descendants), and the .button rule’s var(--button-bg) picks up the new, more specific value. --button-fg is untouched, so the text stays white in both cases.

This demonstrates variable scoping in action: --button-bg isn’t one single value—it’s resolved per element based on the cascade, so a single rule (.button) can render differently depending on which class also happens to be present.

Example 3: Responsive spacing scale with calc()

:root {
  --spacing-unit: 8px;
  --content-width: min(90vw, 1200px);
}

.container {
  width: var(--content-width);
  margin-inline: auto;
  padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
}

@media (min-width: 900px) {
  :root {
    --spacing-unit: 12px;
  }
}

Result: On narrow viewports, .container is centered, capped at 90% of the viewport width (or 1200px, whichever is smaller), with 16px of vertical padding and 24px of horizontal padding (8px times 2 and 8px times 3). Once the viewport reaches 900px wide, --spacing-unit becomes 12px, and because .container‘s padding is computed from that variable via calc(), the padding smoothly becomes 24px vertical and 36px horizontal—no separate breakpoint-specific padding rule was needed.

This is the pattern that makes custom properties genuinely powerful compared to static values: one variable change inside a media query cascades into every calculation that depends on it, keeping a whole spacing (or color, or typography) scale consistent at each breakpoint.

How it works step by step (under the hood)

When the rendering engine builds computed styles for an element, custom properties go through a distinct resolution process from ordinary properties:

  • 1. Parse and store, unvalidated. When the engine encounters --name: value;, it stores the token sequence as-is. Because there is no fixed grammar for a custom property’s value, the parser accepts almost anything as long as it’s a balanced, well-formed token sequence.
  • 2. Resolve via the cascade. For a given element, the engine collects every declaration of --name that applies (from any matching selector, at any specificity, from any stylesheet origin) and picks the winner using the same cascade rules used for any other property: origin/importance, specificity, then source order.
  • 3. Inherit if unset. If no rule sets --name directly on the element, the engine looks at the parent’s computed value and inherits it, since custom properties are inheritable by default.
  • 4. Substitute at var() sites. When the engine computes the value of a real property like color or padding that contains var(--name), it replaces the var() reference with the resolved token sequence from step 2/3, using the element’s own resolved value—not the value at the point of declaration.
  • 5. Re-validate for the target property. The substituted result is parsed again, this time against the grammar of the specific property it was substituted into. If it’s invalid there (say, a variable meant for a color is substituted into margin), the whole declaration becomes invalid at computed-value time, and the property computes to its inherited or initial value.
  • 6. Fallback only for missing variables. If --name was never declared anywhere in the inheritance chain and no fallback was given, var(--name) resolves to nothing (the guaranteed-invalid value), which usually makes the whole declaration invalid. Providing var(--name, fallback) avoids this by substituting fallback whenever --name is completely absent.

Modern CSS also offers @property, which lets you register a custom property with an explicit syntax (like <color> or <length>), a default value, and whether it inherits—this enables the browser to interpolate the variable smoothly during transitions and animations, something plain untyped custom properties cannot do.

Common Mistakes

Mistake 1: Forgetting the double-hyphen prefix

A custom property name must start with --. Leaving it off doesn’t create a variable at all—it just creates an unrecognized property that the browser silently ignores, and var() has nothing valid to read.

:root {
  main-color: #3366ff;
}

.card {
  color: var(--main-color);
}

Here main-color (no leading hyphens) is not the same thing as --main-color. The browser treats it as an unknown property and drops it, so .card‘s color falls back to its inherited value instead of blue. The fix is to always prefix custom property declarations with two hyphens:

:root {
  --main-color: #3366ff;
}

.card {
  color: var(--main-color);
}

Mistake 2: Storing a bare number where a length is expected

Custom property values are stored as raw tokens with no unit-checking, so it’s easy to store a unitless number and then use it directly where a length is required.

:root {
  --gap: 16;
}

.grid {
  display: grid;
  gap: var(--gap);
}

The value 16 has no unit, and gap requires a length (like 16px) or percentage, so this declaration is invalid at computed-value time and gap falls back to its initial value of normal—the grid tracks end up with no gap at all, which is easy to miss visually. Either store the unit in the variable, or multiply it inside calc() with a unit supplied there:

:root {
  --gap: 16px;
}

.grid {
  display: grid;
  gap: var(--gap);
}

Best Practices

  • Declare page-wide design tokens (colors, spacing scale, font stacks, border-radius) on :root so they’re available everywhere by inheritance.
  • Use kebab-case, semantic names like --color-primary or --space-md instead of literal names like --blue, so the meaning survives if the actual value changes later.
  • Scope component-specific variables to the component’s own class instead of polluting :root, especially for values that only make sense inside that component.
  • Provide a sensible var(--name, fallback) fallback for any variable a component exposes as part of its public styling API, so it still renders reasonably if the consumer forgets to define it.
  • Combine variables with calc() to build proportional scales (spacing, type sizes) from a small number of base variables rather than hardcoding every step.
  • Reach for @property when you need a custom property to animate or transition smoothly, or when you want the browser to enforce a specific value type.
  • Remember custom properties inherit—an unexpected value on a deeply nested element is often coming from a redeclaration higher up the tree, not from the rule you’re currently looking at.

Practice Exercises

Exercise 1: Create three custom properties on :root for a color palette (--color-primary, --color-secondary, --color-background), then write a rule for a .banner class that uses all three via var() for its background, text color, and border color.

Exercise 2: Define --radius: 8px on :root, then write a .card--rounded class that overrides --radius to 20px only for elements with that class, and apply border-radius: var(--radius) in the base .card rule. Confirm in your head which elements would render with which radius.

Exercise 3: Using var(--name, fallback), write a .badge rule that reads a variable --badge-color with a fallback of gray, so the badge still looks reasonable even on a page that never defines --badge-color.

Summary

  • Custom properties (CSS variables) are declared as --name: value; inside a rule, and read with var(--name) or var(--name, fallback).
  • They live in the cascade and inherit down the DOM tree, just like ordinary inherited properties—declaring them on :root makes them available page-wide.
  • Values are stored unvalidated as raw tokens; validity is only checked when substituted into a real property via var().
  • The fallback in var(--name, fallback) only applies when the variable is entirely undefined, not when its value is merely invalid for that property.
  • Combine variables with calc() and media queries to build responsive, themeable scales without duplicating values.
  • Use @property when you need a typed, animatable custom property.