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)); }
:rootis thehtmlelement — 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;thengrid-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);
}