Lesson 21 of 25

CSS Variables (Custom Properties)

Values With a Name, Alive in the Browser

A custom property — usually called a CSS variable — stores a value under a name you choose, and any rule can read it back with var(). Declare it with two leading hyphens: --brand: #d1039e. Use it with var(--brand). The names are case-sensitive, so --Brand and --brand are two different things.

If you have met variables in Sass or Less, the important difference is that those are resolved once when the stylesheet is compiled and then vanish. CSS custom properties are real values in the browser at runtime. They cascade, they inherit, they can be redefined for one part of the page, they can change inside a media query, and JavaScript can read and rewrite them while the page is open. That is what makes theming, and a great deal else, possible without regenerating a stylesheet.

The usual place for global values is the :root selector, which matches the <html> element. Because custom properties inherit, anything declared there is visible to every element on the page. A dozen or so values — brand colours, a spacing scale, a border radius, a standard transition duration — will cover most of a small site, and changing the whole look becomes a matter of editing that one block.

Example
/* Define once, at the top of the stylesheet */
:root {
  --brand:        #d1039e;
  --brand-dark:   #7a0060;
  --text:         #222222;
  --text-muted:   #555555;
  --surface:      #ffffff;
  --page:         #f4f4f7;
  --border:       #e3e3ea;

  --radius:       10px;
  --space:        16px;
  --transition:   0.2s ease-out;
}

/* Use everywhere */
body   { background: var(--page); color: var(--text); }

.card  {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: calc(var(--space) * 1.5);
}

.btn {
  background: var(--brand);
  border-radius: var(--radius);
  transition: background-color var(--transition);
}
.btn:hover { background: var(--brand-dark); }

/* A fallback for when the property is not defined */
.widget { color: var(--widget-text, var(--text)); }
  • Names start with -- and are case-sensitive
  • Read them with var(--name), optionally with a fallback: var(--name, #333)
  • Declared on :root they are available to the whole page
  • They inherit, so a value set on a component reaches everything inside it
  • They exist at runtime, so JavaScript and media queries can change them
  • The fallback is used when the property is undefined, not when its value is wrong

Scope and Inheritance: the Part That Actually Saves Work

Custom properties are not limited to :root. Declare one on any selector and it applies to that element and everything inside it, overriding an outer value for that subtree only. This is where they stop being a convenience and start changing how you write components.

Take a card that comes in three flavours: normal, featured and archived. The old approach writes three sets of rules, each restating the background, the border, the heading colour and the button colour. The variable approach writes the card's rules once in terms of variables, and each flavour redefines two or three values. Adding a fourth flavour later is three lines rather than fifteen, and the flavours can never drift apart, because they share one implementation.

The same trick works for sizes. A component that expresses its padding and font size in terms of a local --size variable can offer a compact and a large version by changing that one number. And because the values inherit, a variable set on a section quietly re-themes every component inside it — a dark band on a landing page can simply redefine --surface and --text and let the cards inside adapt themselves.

Example
/* One implementation, expressed in variables */
.card {
  --card-bg:     var(--surface);
  --card-border: var(--border);
  --card-accent: var(--brand);

  background: var(--card-bg);
  border: 1px solid var(--card-border);
  border-radius: var(--radius);
  padding: 20px;
}

.card h3   { color: var(--card-accent); }
.card .btn { background: var(--card-accent); }

/* Variants redefine values, not rules */
.card--featured {
  --card-bg:     #fdf2fa;
  --card-border: var(--brand);
}

.card--archived {
  --card-bg:     #f8f8fb;
  --card-accent: #9a9aa6;
}

/* A whole section re-themed by two declarations */
.section--dark {
  --surface: #23232b;
  --border:  #3a3a45;
  --text:    #ececf2;
  background: #14141a;
  color: var(--text);
}
Notes
  • Because a variable declared on an element is visible to its descendants, you can define a component's whole palette on the component itself. Anyone reading .card then sees the values it depends on in one place, instead of hunting through the file for the colours it happens to use.

Theming: Dark Mode and Runtime Changes

Dark mode is the clearest demonstration of why runtime variables matter. Write your entire stylesheet in terms of semantic names — --surface, --text, --border — rather than literal colours, and a second theme is one block that redefines those names. Not a single component rule changes.

There are two triggers worth supporting together. @media (prefers-color-scheme: dark) follows the visitor's operating system setting, which is the right default because most people have already made that choice once and expect sites to respect it. A data-theme attribute on <html>, set by a small script, gives them an explicit switch on your site and lets the preference be remembered. Written in that order, the attribute wins when it is present and the system preference applies when it is not.

Note the naming discipline that makes this work. A variable called --white becomes a lie the moment it holds #14141a in dark mode. Name variables for the job they do — surface, text, muted, border, accent — not for the colour they happen to be today.

JavaScript can read and write these values directly, which is how you build a live colour picker, a font-size control, or a progress bar whose width comes from real data. One setProperty call on the root element updates every rule that uses the variable, everywhere on the page, at once.

Example
/* Light theme, expressed semantically */
:root {
  --surface: #ffffff;
  --page:    #f4f4f7;
  --text:    #222222;
  --muted:   #555555;
  --border:  #e3e3ea;
}

/* Follow the operating system */
@media (prefers-color-scheme: dark) {
  :root {
    --surface: #23232b;
    --page:    #14141a;
    --text:    #ececf2;
    --muted:   #a8a8b6;
    --border:  #3a3a45;
  }
}

/* An explicit switch on the site, which overrides the system */
[data-theme="dark"] {
  --surface: #23232b;
  --page:    #14141a;
  --text:    #ececf2;
  --muted:   #a8a8b6;
  --border:  #3a3a45;
}

/* JavaScript can change any of them at runtime */
// document.documentElement.setAttribute('data-theme', 'dark');
// document.documentElement.style.setProperty('--brand', '#0a58ca');
// getComputedStyle(document.documentElement).getPropertyValue('--brand');

/* A progress bar driven by a variable */
.bar { width: var(--progress, 0%); background: var(--brand); height: 8px; }
// el.style.setProperty('--progress', '62%');
  • Name variables for their role — --surface, --text — not for their current colour
  • Support prefers-color-scheme first, then an explicit override attribute
  • Test contrast in both themes; a palette that passes in light mode can fail in dark
  • element.style.setProperty('--x', value) sets a variable from JavaScript
  • getComputedStyle(el).getPropertyValue('--x') reads one back
  • Setting a variable on :root updates every rule that uses it, instantly

Four Limits That Will Catch You Out

You cannot use a variable in a media query condition. @media (min-width: var(--bp)) does not work, because media queries are evaluated before custom properties are resolved. Breakpoint values have to be written out literally, or generated by a build tool. This surprises people who have carefully centralised every other value.

You cannot glue a unit onto a number by concatenation. If --gap holds 16, then margin: var(--gap)px is invalid — the browser does not paste text together. Either store the unit in the value (--gap: 16px), which is usually simplest, or multiply: margin: calc(var(--gap) * 1px).

An invalid value does not fall back to the previous declaration. Normally a bad declaration is dropped and whatever was there before still applies. With var(), the browser cannot tell the value is nonsense until it has already committed to the declaration, so instead the property becomes unset — inheriting from the parent for an inherited property, or reverting to its initial value otherwise. A mistyped colour variable can therefore produce black text or a transparent background rather than the previous colour, which makes the cause harder to spot.

Custom properties are not animatable by default. Putting a transition on a property whose value comes from a variable and then changing the variable gives you an instant jump, because as far as the browser is concerned the variable is an untyped string. The newer @property rule lets you register a custom property with a type and an initial value, and registered properties can be animated. Where that is not available, animate the real property instead.

Example
/* 1. Not allowed — media queries cannot read variables */
:root { --bp-tablet: 768px; }
@media (min-width: var(--bp-tablet)) { }   /* never matches */
@media (min-width: 768px) { }              /* write it out */

/* 2. Units cannot be concatenated */
:root { --gap: 16; }
.a { margin: var(--gap)px; }                 /* invalid */
.b { margin: calc(var(--gap) * 1px); }       /* works */
:root { --gap: 16px; }
.c { margin: var(--gap); }                   /* simplest */

/* 3. An invalid var() unsets the property */
.card {
  color: #222222;
  color: var(--nonexistent-colour);   /* the text may go black, not stay #222 */
}
.card { color: var(--maybe, #222222); }   /* a fallback avoids it */

/* 4. Registering a property so it can animate */
@property --shade {
  syntax: '<color>';
  inherits: false;
  initial-value: #d1039e;
}

.chip { background: var(--shade); transition: --shade 0.3s ease; }
.chip:hover { --shade: #7a0060; }
Notes
  • Always give var() a sensible fallback when the property might legitimately be missing — for a component variable a page may not have set, or for anything read from JavaScript. It costs a few characters and turns a mysteriously broken layout into a slightly plainer one.
Ask AI