CSS variables are officially called custom properties: properties whose names start with two hyphens, read back with the var() function. They are defined in CSS Custom Properties for Cascading Variables Module Level 1, a Candidate Recommendation, and typed custom properties with @property come from CSS Properties and Values API Level 1. Custom properties and var() work in Chrome 49, Edge 15, Firefox 31 and Safari 9.1 and later (MDN browser-compat-data 8.1.4).

Because they take part in the cascade and are substituted late, custom properties behave differently from preprocessor variables in a few ways that cause real bugs: a fallback that is never used, a declaration that resets to zero, a variable that does nothing inside a media query. This guide goes through the spec sections behind those cases and checks each one in a browser. All measurements were taken on 2026-10-02 in Chrome 152 on macOS, by applying the declarations to test elements and reading getComputedStyle().

Declaring and reading a custom property

A custom property is declared like any other property, on any selector, and read with var():

:root {
  --brand: #3b82f6;
  --space-4: 16px;
}
.button {
  background: var(--brand);
  padding: var(--space-4);
}

Section 2 sets the rules for names and values:

  • The name must start with --. After that almost anything goes, including digits and non-ASCII letters.
  • Names are case-sensitive. --brand and --Brand are different properties. With --primary: red set, color: var(--Primary, rgb(1, 2, 3)) computed to rgb(1, 2, 3): the fallback, because --Primary did not exist.
  • The value is a sequence of tokens that is not checked against any property grammar when it is declared. --x: 10px, --x: 1px solid and --x: if you like are all valid declarations; whether they work depends on where they are used.

The browser keeps the value almost as written. Reading back a few declarations with getPropertyValue():

DeclarationgetPropertyValue() returned
--x: 10px ;"10px" (leading and trailing spaces removed)
--y: calc( 1px + 2px );"calc( 1px + 2px )" (not evaluated)
--z: #3B82F6;"#3B82F6" (case kept)
--foo: ;"" (an empty but valid value)

An unregistered custom property is never computed to a length or a colour. It is only text until a var() puts it into a real property.

Inheritance and scope

Custom properties are inherited. A value set on an element applies to all its descendants until one of them sets the property again. With --space: 8px on an outer element and --space: 16px on an inner one, a padding: var(--space) inside the inner element computed to 16px, and the same rule directly inside the outer element computed to 8px. This is how component-level overrides work:

:root  { --radius: 8px; }
.card  { border-radius: var(--radius); }
.compact { --radius: 4px; }  /* every .card inside .compact uses 4px */

:root matches the html element and has the specificity of one class, (0, 1, 0). A rule such as .theme-dark { --bg: #111111; } applied to the same html element has equal specificity, so whichever comes later in the stylesheet wins. With :root { --bg: white; } first and .theme-dark after it, adding the theme-dark class to html changed the computed --bg from white to #111111.

var() fallbacks

var() takes an optional second argument (§3):

.box { padding: var(--gap, 8px); }
.text { font-family: var(--font, Inter, sans-serif); }
.nested { margin: var(--m, var(--space-2, 4px)); }

Everything after the first comma is the fallback, commas included, so var(--font, Inter, sans-serif) with --font undefined computed to the font list Inter, sans-serif. A fallback can itself contain var().

The fallback is used only when the variable holds the guaranteed-invalid value (§2.2). That happens when the property was never declared on the element or its ancestors, when it was set to initial, or when it is part of a dependency cycle (§2.3). It does not happen when the variable has a value that makes no sense for the property where it is used. Three measurements on an element whose parent had color: green:

Declarations on the elementComputed color
--foo: initial; color: var(--foo, red)red (fallback used)
--foo: ; color: var(--foo, red)green (empty value substituted, declaration invalid)
--a: var(--b); --b: var(--a); padding: var(--a, 6px)padding 6px (cycle, so fallback used)

The second row is the one that surprises people. Section 2.2 notes that --foo: ; “is a valid (empty) value, not the guaranteed-invalid value”, so the fallback is skipped, color: with nothing after it is not a valid colour, and the element ends up with the inherited green. Why it is green and not red is the subject of the next section.

Invalid at computed-value time

When a property contains var(), the browser cannot check it at parse time, because it does not know the variable’s value yet. It keeps the declaration and checks it after substitution. If the result is invalid, the declaration becomes invalid at computed-value time (§3.1), and the property gets its inherited value if it is an inherited property, or its initial value if it is not. It does not fall back to an earlier declaration in the cascade.

DeclarationsComputed value
--gap: red; padding: var(--gap, 8px)padding: 0px
padding: var(--undefined-gap, 8px)padding: 8px
padding: 4px; padding: redpadding: 4px
--gap: red; padding: 4px; padding: var(--gap)padding: 0px
parent color: rgb(0, 0, 255); child --c: 20px; color: var(--c)child color: rgb(0, 0, 255)

Compare the third and fourth rows. A plainly invalid padding: red is dropped when the stylesheet is parsed, so the earlier padding: 4px still applies. padding: var(--gap) is accepted at parse time and wins the cascade; only later does it turn out to be padding: red, and by then the 4px declaration has already lost. Padding is not inherited, so it resets to its initial value, 0. Colour is inherited, so the last row shows the parent’s blue.

The practical consequence: a fallback inside var() protects you from a missing variable, not from a wrong one. If a token can be set to an unsuitable value, register its type.

Registering a type with @property

@property gives a custom property a syntax, an initial value and an inheritance setting:

@property --gap {
  syntax: '<length>';
  inherits: false;
  initial-value: 8px;
}

With this registration in place:

  • --gap: red; padding: var(--gap, 20px) computed to padding: 8px. The value red does not match <length>, so --gap takes its initial value (§2.4 of the Properties and Values API), which is a valid length; neither the fallback nor the reset to 0 applies.
  • With inherits: false, a child of an element that set --gap: 20px read 8px. An unregistered property in the same position read the inherited 20px.
  • The computed value is now a real value: a registered <length> declared as calc(1px + 2px) read back as "3px".
  • It can be animated. Animating a registered <color> property from red to blue and pausing at 25% gave rgb(191, 0, 64). The same animation on an unregistered property read red at 25% and blue at 60%: unregistered values cannot be interpolated, so they flip at the midpoint.

@property works in Chrome and Edge 85, Firefox 128 and Safari 16.4 and later (MDN browser-compat-data 8.1.4). In older browsers the rule is ignored and the property behaves as an unregistered one, so keep a sensible value declared normally as well.

Reading and writing from JavaScript

getComputedStyle() returns the computed value, which for an unregistered property is the substituted token text, and style.setProperty() writes an inline value:

const el = document.querySelector('.card');
getComputedStyle(el).getPropertyValue('--radius'); // "8px"
el.style.setProperty('--radius', '24px');
getComputedStyle(el).getPropertyValue('--radius'); // "24px"
document.documentElement.style.setProperty('--brand', '#e11d48');

el.style.getPropertyValue() reads only the inline style, so it returns an empty string for a value set in a stylesheet. Remember the case-sensitivity: getPropertyValue('--Radius') returns "". Setting a property on :root re-themes the whole page in one call, because every element inherits it and recomputes the declarations that use it.

CSS variables vs Sass variables

Sass variables exist only while the stylesheet is compiled; the output contains plain values. CSS variables exist in the browser, follow the DOM, and can change at run time. Compiled with Dart Sass 1.105.1:

$space: 8px;
:root {
  --space: $space;
  --space-i: #{$space};
}
.card {
  padding: $space * 2;
  margin: calc(var(--space) * 2);
}
@media (min-width: $space * 100) {
  .card { padding: $space * 3; }
}
:root {
  --space: $space;
  --space-i: 8px;
}

.card {
  padding: 16px;
  margin: calc(var(--space) * 2);
}

@media (min-width: 800px) {
  .card {
    padding: 24px;
  }
}

Two differences show up. Sass leaves custom property values alone, so --space: $space is written out literally and the browser gets a variable containing the text $space; interpolation with #{$space} is needed to copy the Sass value in. And Sass can compute a media query, while CSS cannot: var() is only allowed in property values, and Chrome’s matchMedia('(min-width: var(--bp))') returned false even with --bp: 10px set on the root, while (min-width: 10px) returned true. Breakpoints have to stay as literal values (or Sass variables).

The usual split is to keep the token values in one place and emit them as custom properties, using Sass, if at all, only for things CSS cannot do, such as loops and media query values.

Dark mode with custom properties

Custom properties make theming a matter of swapping values, not rewriting rules:

:root {
  color-scheme: light dark;
  --bg: #ffffff;
  --text: #111827;
}
@media (prefers-color-scheme: dark) {
  :root { --bg: #111827; --text: #f9fafb; }
}
:root[data-theme="dark"]  { --bg: #111827; --text: #f9fafb; }
:root[data-theme="light"] { --bg: #ffffff; --text: #111827; }

body { background: var(--bg); color: var(--text); }

The media query follows the operating system, and the data-theme attribute lets a toggle override it; :root[data-theme] has higher specificity than :root, so it wins over the media query block regardless of order. color-scheme tells the browser to draw form controls and scrollbars in the matching scheme.

Newer browsers can do the same without a second block, using light-dark(), which picks one of two colours based on the element’s used colour scheme. With color-scheme: light dark on a light system, color: light-dark(#111111, #eeeeee) computed to rgb(17, 17, 17); on a child with color-scheme: dark it computed to rgb(238, 238, 238). light-dark() works in Chrome 123, Firefox 120 and Safari 17.5 and later, and it only accepts colours, so spacing or shadow tokens that change between themes still need custom properties.

Generating a token block with the generator

The CSS variables generator builds one :root block from four fixed groups (Colors, Typography, Spacing, Border Radius). Each row has a name and a value, colour rows also have a colour picker, and every group has an + Add Token button. With the default prefix -- and the pre-filled rows it writes:

:root {
  --primary: #3b82f6;
  --secondary: #6366f1;
  --bg: #ffffff;
  --text: #111827;
  --font-sans: Inter, system-ui, sans-serif;
  --font-mono: "Fira Code", monospace;
  --font-size-base: 16px;
  --font-size-lg: 18px;
  --space-1: 4px;
  --space-2: 8px;
  --space-4: 16px;
  --space-8: 32px;
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
  --radius-full: 9999px;
}

The prefix field adds a namespace. The tool adds the missing hyphens, so ds, --ds and --ds- all give names such as --ds-primary, and spaces inside a name become hyphens. Values are copied as typed, so a token can refer to another:

:root {
  --ds-primary: #3b82f6;
  --ds-space-2: 8px;
  --ds-space-4: calc(var(--ds-space-2) * 2);
}

The output follows the rules above, so the same caveats apply. Names are case-sensitive, and two rows with the same name both appear in the block (the later one wins). Values are not validated, so a colour typed as #3b82f is copied as is and makes every property that uses it invalid at computed-value time. The colour picker follows only 3- and 6-digit hex values; rgb() or oklch() values are copied correctly but the swatch does not update. There is no output for a dark theme, @property registrations or media queries. Copy the colour rows into a second block by hand, as in the dark mode example above.