Custom properties

Use custom properties for theming and for values that change, and read them from JavaScript.

17of 26
≈ 11Min read
9Parts

They are properties, not variables

A custom property is a real CSS property whose name starts with --. That means it inherits, it takes part in the cascade, and it can be different on different elements. A preprocessor variable is a compile-time find-and-replace; this is not.

define and use
:root {
  --brand: #f1521f;
  --space: 1rem;
  --radius: 12px;
}

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

:root is the html element. Defining there means "available everywhere", because everything inherits from it.

Fallbacks

the second argument
color: var(--ink, #15161a);
padding: var(--space, var(--space-fallback, 1rem));   /* they nest */
An invalid value is worse than a missing one

If --gap: banana and you write gap: var(--gap), the browser cannot know it is nonsense until it substitutes — and by then the declaration is "invalid at computed-value time", which makes the property fall back to inherited or initial, not to your previous rule. A typo in a custom property can therefore undo a whole rule.

Scoping is the useful part

Because they inherit, you can redefine a property on a subtree and everything inside it changes. That is how a component takes a variant with one class.

one component, three looks
.card {
  --card-bg: #ffffff;
  --card-ink: #15161a;

  background: var(--card-bg);
  color: var(--card-ink);
}

.card.dark {
  --card-bg: #14161a;
  --card-ink: #e9ebee;
}

.card.warning {
  --card-bg: #fff6ed;
  --card-ink: #7a3b12;
}

The .card rule never changes. Every variant is two lines, and nothing about the component's layout has to be repeated.

Theming

Every rule in the stylesheet refers to var(--bg) and var(--ink). Switching theme is one attribute on <html>, and nothing else in the CSS knows it happened.

one set of tokens, two themes
:root {
  color-scheme: light dark;
  --bg: #ffffff;
  --ink: #15161a;
  --line: rgb(0 0 0 / .12);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0b0d;
    --ink: #e9ebee;
    --line: rgb(255 255 255 / .12);
  }
}

/* and an explicit override wins over the system */
[data-theme="dark"] {
  --bg: #0a0b0d;
  --ink: #e9ebee;
  --line: rgb(255 255 255 / .12);
}

They can hold anything

They can even hold a fragment of a value, though that is usually a step too clever: --hue: 18; color: hsl(var(--hue) 86% 45%); is genuinely useful, because now one number drives a whole palette.

a custom property is an arbitrary token stream
:root {
  --shadow: 0 8px 24px -12px rgb(0 0 0 / .4);
  --ease: cubic-bezier(.2, .7, .1, 1);
  --font: "Space Grotesk", system-ui, sans-serif;
  --cols: repeat(auto-fit, minmax(16rem, 1fr));
}

.panel {
  box-shadow: var(--shadow);
  transition: transform .4s var(--ease);
  grid-template-columns: var(--cols);
}

calc and custom properties

a spacing scale from one number
:root {
  --space: 0.5rem;
}

.tight  { padding: var(--space); }
.normal { padding: calc(var(--space) * 2); }
.loose  { padding: calc(var(--space) * 4); }
Units in calc

A custom property holding a bare number works in calc(var(--n) 1px). A property holding 10px cannot be multiplied by 2px — calc(10px 2px) is invalid. Keep the unit on one side only.

Reading and writing from JavaScript

This is the cleanest bridge between JavaScript and CSS: the script sets one number, the stylesheet decides what it means. Nothing about the look lives in the script.

the live value
const root = document.documentElement;

// read the computed value
getComputedStyle(root).getPropertyValue("--brand").trim();   // "#f1521f"

// write one — this is a normal inline style, so it wins
root.style.setProperty("--brand", "#2f6fed");

// remove it again
root.style.removeProperty("--brand");

// per element: a progress bar with no width calculation in CSS
bar.style.setProperty("--p", 0.62);
and the CSS that uses it
.bar::after {
  width: calc(var(--p, 0) * 100%);
}

@property

By default a custom property is an untyped string, which is why gradients and transforms built from one cannot be animated. @property gives it a type, an initial value and whether it inherits — and typed properties can be interpolated.

an angle you can animate
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.ring {
  background: conic-gradient(from var(--angle), #f1521f, #14161a, #f1521f);
  transition: --angle 1.2s linear;
}

.ring:hover { --angle: 360deg; }

What not to do

  • Do not define hundreds of them. A token set of 20–40 that everything refers to is useful; one per declaration is just indirection.
  • Name them for their role, not their value: --line, not --grey-12. The value changes; the role does not.
  • Do not put the same value in two properties. If --border and --divider are always the same colour, there is one token.

Practice