AniUI Academy

CSS Custom Properties (Variables)

Declaring --custom properties on :root, reading them with var(--name, fallback), the one-line theme-change use case, and how they inherit and can be overridden per scope.

8 min read

Real stylesheets repeat the same values constantly — the same brand blue in a dozen places, the same spacing unit between a dozen sections. Custom properties (informally called "CSS variables") let you name a value once and reference it everywhere, so changing it later is a one-line edit instead of a find-and-replace across the whole file.

Declaring a custom property

Any property name starting with two dashes is a custom property:

:root {
  --brand-color: #3b82f6;
  --spacing-unit: 1rem;
}

:root is a selector matching the document's root element (<html>), and it's the conventional place to declare custom properties meant to apply page-wide — since every other element on the page is a descendant of <html>, anything declared on :root is reachable from anywhere via inheritance.

Reading a custom property with var()

.button {
  background-color: var(--brand-color);
  padding: var(--spacing-unit);
}

var(--brand-color) reads the current value of --brand-color in that element's scope and substitutes it in, as if you'd typed the value directly.

var() also accepts a second argument — a fallback used if the custom property isn't defined at all:

.button {
  color: var(--text-color, #1e293b);
}

If --text-color has never been declared anywhere in scope, .button's text renders #1e293b instead. This is genuinely useful for a component you might drop into a page that doesn't define every theme variable, or for guarding against a typo in a variable name breaking a whole rule silently.

The real use case: a one-line theme change

The payoff shows up once a color (or spacing value, or font stack) is used in more than one place. Without custom properties, a rebrand means finding and replacing the same hex code everywhere it was hardcoded:

/* Without custom properties: the same color repeated, and easy to miss one */
.button    { background-color: #3b82f6; }
.link      { color: #3b82f6; }
.badge     { border-color: #3b82f6; }
/* With a custom property: one declaration, referenced everywhere */
:root {
  --brand-color: #3b82f6;
}
 
.button    { background-color: var(--brand-color); }
.link      { color: var(--brand-color); }
.badge     { border-color: var(--brand-color); }

Change the brand color for the whole site by editing the single :root declaration — every rule referencing var(--brand-color) updates automatically. This is the same one-change-many-places idea you already saw with external stylesheets versus inline styles, one level more granular: now even a single value inside a stylesheet doesn't need to be repeated by hand.

Custom properties inherit and cascade like normal properties

This is the part worth understanding properly, because it's what makes custom properties more than "named constants" — they behave like real CSS properties, which means the cascade and inheritance rules you already know apply to them directly.

A custom property declared on :root inherits down through every descendant, same as color or font-family would. And just like any other inherited property, you can redeclare it on a narrower selector, and that new value applies only within that selector's scope — to it and its descendants — without affecting anything outside it:

:root {
  --surface-color: white;
}
 
.card {
  background-color: var(--surface-color);
}
 
.card--dark {
  --surface-color: #0f172a; /* overrides, but only inside .card--dark */
  color: white;
}

Every .card uses whichever --surface-color is in effect for it. A plain .card inherits the page-wide white from :root. A .card--dark element redeclares --surface-color on itself, so it and anything nested inside it see #0f172a instead — while every other card on the page is completely unaffected, because the override never escapes .card--dark's own scope.

This scoped-override behaviour is what makes custom properties genuinely useful for theming: define sensible page-wide defaults on :root, then override just the ones that need to differ, on just the section that needs them to differ, and every rule that reads the variable via var() picks up the right value automatically depending on where in the tree it sits.

Try it yourself

--surface-color and --text-color are set once on :root, then overridden inside .dark-section only. Every rule below just reads var(...) — none of them hardcode a color a second time.

Try it yourself
Loading playground...

What to remember

  • Custom properties are named with two leading dashes (--name: value;) and typically declared on :root for page-wide scope.
  • var(--name) reads the value; var(--name, fallback) supplies a fallback used when the property is undefined.
  • The real payoff is a one-line theme change: reference a value everywhere via var(), and updating the single declaration updates every usage.
  • Custom properties inherit and cascade like normal CSS properties — redeclaring one inside a narrower selector overrides it only for that selector's scope and its descendants, leaving everything outside untouched.

Check yourself

2 questions · pass 2/2 to unlock Shadows, Borders and Gradients

up to 50
  1. 1.What does color: var(--brand-color, blue); do if --brand-color has never been declared anywhere?

  2. 2.A custom property --gap is set to 1rem on :root, then redeclared as 2rem inside .card. What is --gap equal to for an element inside .card?

2 left to answer