Declare a CSS variable with two dashes, --brand: #2563eb;, usually inside :root so the whole page can see it, and read it with var(--brand). A second argument is the fallback: var(--brand, blue). Variables inherit like color does, so redefining one on a section changes it for everything inside, which makes themes cheap.
| Task | Code |
|---|---|
| Declare globally | :root { --gap: 1rem; } |
| Use | padding: var(--gap); |
| Fallback | var(--gap, 16px) |
| Override locally | .card { --gap: 0.5rem; } |
| Read in JS | getComputedStyle(el).getPropertyValue('--gap') |
| Write in JS | el.style.setProperty('--gap', '2rem') |
Updated
This guide covers declaring, scoping and falling back, then a theme switcher where only variables change, a tool that reads and writes variables from JavaScript on this very page, registered properties with @property, how Tailwind v4 turns its theme into variables, and the mistakes that make var() silently fail.
| Token | Light | Dark |
|---|---|---|
| --color-bg | #ffffff | #0f172a |
| --color-surface | #f1f5f9 | #1e293b |
| --color-text | #0f172a | #e2e8f0 |
| --color-muted | #475569 | #94a3b8 |
| --color-accent | #2563eb | #60a5fa |
| --color-border | #cbd5e1 | #334155 |
The second card redefines --accent for itself. The third uses a variable nobody defined, so the fallback colour shows.
<style>
:root { --accent: #2563eb; --radius: 10px }
.card { border: 3px solid var(--accent); border-radius: var(--radius); padding: 8px 12px; margin: 6px 0 }
.warm { --accent: #ea580c }
.ghost { border-color: var(--not-defined, #16a34a) }
</style>
<div class="card">Uses the root accent</div>
<div class="card warm">Overrides --accent locally</div>
<div class="card ghost">Falls back to green</div>One set of component rules. The theme is a different set of values, chosen by the system setting or by a data-theme attribute.
:root {
--color-bg: #ffffff;
--color-text: #0f172a;
--color-accent: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #0f172a;
--color-text: #e2e8f0;
--color-accent: #60a5fa;
}
}
:root[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #e2e8f0;
--color-accent: #60a5fa;
}
body { background: var(--color-bg); color: var(--color-text); }
a { color: var(--color-accent); }Read the computed value from any element. Write on :root for a global change, or on one element to affect only its subtree.
const root = document.documentElement;
// read (returns a string, may include leading spaces in older browsers)
const accent = getComputedStyle(root).getPropertyValue("--color-accent").trim();
// write globally
root.style.setProperty("--color-accent", "#16a34a");
// write on one element, e.g. follow the pointer
card.addEventListener("pointermove", (e) => {
const r = card.getBoundingClientRect();
card.style.setProperty("--x", `${e.clientX - r.left}px`);
});
// remove an inline override
root.style.removeProperty("--color-accent");Registering --angle as an <angle> lets the browser interpolate it. Without the registration the gradient would jump instead of turning.
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.ring {
background: conic-gradient(from var(--angle), #2563eb, #f97316, #2563eb);
animation: spin 4s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
@media (prefers-reduced-motion: reduce) { .ring { animation: none; } }In Tailwind v4, variables in @theme generate utilities such as bg-brand and are also emitted as real CSS variables you can use with var().
@import "tailwindcss";
@theme {
--color-brand: #2563eb;
--font-display: "Inter", sans-serif;
--radius-card: 14px;
}
/* now available: bg-brand, text-brand, font-display, rounded-card */
.custom { border-color: var(--color-brand); }| Problem | Why | Fix |
|---|---|---|
| var() in @media condition | Media conditions are not evaluated per element | Change the variable inside the media block instead |
| Value with units fails | --n: 4; then width: var(--n)px makes '4 px' | Use calc(var(--n) * 1px) |
| Property falls to initial | Invalid at computed-value time | Check the substituted value, add a fallback |
| Variable ignored in url() | url(var(--x)) is not allowed | Store the whole url(...) in the variable |
| Derived variable ignores an override | :root { --big: calc(var(--base) * 2) } is resolved on :root, so .card { --base: 5px } does not change --big | Redeclare --big on .card too, or compute it where it is used |
A custom property is an ordinary inherited property. Defined on :root, it reaches every element. Defined on .card, it applies to that card and its descendants only, and it beats the :root value there because the nearer value is inherited.
That is what makes component variants short. A button reads var(--btn-bg), and .btn-danger just sets --btn-bg: var(--color-danger). No second set of rules for every state.
The browser cannot check a var() declaration while parsing, since the value is unknown until it is substituted. So margin: var(--size) with --size: red is accepted at parse time and only fails later.
When it fails, the property is treated as unset. For an inherited property like color that means the parent's colour; for margin it means 0. An earlier valid margin rule in your stylesheet does not come back, which surprises people used to normal cascade fallbacks.
Sass variables are replaced when the stylesheet is compiled and vanish from the output. CSS custom properties exist in the browser at run time, inherit through the DOM, can differ per element and can be changed from JavaScript or by a media query rule after the page loads.
No. var() is not allowed in the condition of an @media rule, because media queries are evaluated without an element to inherit from. You can, however, change a variable's value inside a media query block, which is the usual way to make tokens responsive.
Check the spelling and case, that the variable is defined on the element or an ancestor, and that the final value is valid for the property. If the substituted value is invalid, the declaration becomes invalid at computed-value time and the property falls back to its inherited or initial value, not to your previous rule.
An unregistered variable is treated as a string, so it flips at the midpoint instead of tweening. Register it with @property and a syntax such as <color> or <angle>, and the browser can interpolate it smoothly in transitions and animations.
Custom properties and var() have worked in every major browser for years. @property is newer: Firefox added it in version 128 in 2024, so check caniuse if you support older versions and keep an unregistered fallback.
Related: CSS media queries, Modern CSS features, Colour contrast checker, CSS transitions, CSS gradient generator.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.