Skip to content
AgileLogics
7 min read

Custom Properties

Prerequisites: The cascade, specificity, and inheritance

Custom properties (often called CSS variables) store values you can reuse. Their names start with --:

:root {
  --color-brand: hsl(210 80% 45%);
  --color-text: #1f2328;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --radius: 8px;
}

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

.link { color: var(--color-link, var(--color-brand)); }
  • :root is the html element — defining them there makes them available everywhere.
  • var(--name, fallback) uses a fallback when the property isn't set.
  • Names are case-sensitive.

They cascade and inherit

Unlike Sass variables, custom properties are live values that follow the cascade. Redefine them on any element, and everything inside uses the new value:

<style>
  :root { --accent: rgb(0, 90, 200); }
  .danger { --accent: rgb(200, 30, 30); }
  .button { color: var(--accent); border: 2px solid var(--accent); }
</style>
<button class="button">Save</button>
<div class="danger"><button class="button">Delete</button></div>
<script>
  for (const b of document.querySelectorAll(".button")) {
    const s = getComputedStyle(b);
    console.log(b.textContent, s.color, s.getPropertyValue("--accent"));
  }
  document.documentElement.style.setProperty("--accent", "rgb(0, 128, 0)");
  console.log("after JS:", getComputedStyle(document.querySelector(".button")).color);
</script>
Save rgb(0, 90, 200) rgb(0, 90, 200)
Delete rgb(200, 30, 30) rgb(200, 30, 30)
after JS: rgb(0, 128, 0)

This is why they're perfect for themes: change a few properties on :root (or on a .dark class, or in a media query) and the whole site updates — and JavaScript can change them too.

Theming with a media query

:root {
  --bg: #ffffff;
  --text: #1f2328;
  --muted: #59636e;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0d1117;
    --text: #e6edf3;
    --muted: #9198a1;
  }
}

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

Things to know

  • A custom property holds any tokens; it's only checked when used. If var() produces an invalid value, the declaration becomes "invalid at computed-value time" and falls back to the inherited or initial value — not to an earlier declaration.
  • You can't use them in media query conditions or selectors.
  • Combine with calc(): margin: calc(var(--space-4) * 2);.
  • Unitless numbers are flexible: --columns: 3; then grid-template-columns: repeat(var(--columns), 1fr);.

Typed custom properties

@property registers a property with a type, an initial value, and whether it inherits. The browser can then animate it (for example, a gradient angle), which is impossible for untyped properties:

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

.spinner-border {
  background: conic-gradient(from var(--angle), teal, transparent);
  animation: turn 2s linear infinite;
}

@keyframes turn { to { --angle: 360deg; } }

@property works in all major browsers (Chrome and Edge 85, Safari 16.4, Firefox 128).

Practice

Define spacing tokens --space-1 to --space-3 (0.25rem, 0.5rem, 1rem) and a .stack class that puts --space-3 between its children — but lets a .stack.tight use --space-1.

Show solution
:root {
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 1rem;
}

.stack {
  --gap: var(--space-3);
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

.stack.tight {
  --gap: var(--space-1);
}