CSS Variables: From var() to Themes and @property

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.

Syntax at a glance
TaskCode
Declare globally:root { --gap: 1rem; }
Usepadding: var(--gap);
Fallbackvar(--gap, 16px)
Override locally.card { --gap: 0.5rem; }
Read in JSgetComputedStyle(el).getPropertyValue('--gap')
Write in JSel.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.

Theme tokens

Token names for the theme demo
TokenLightDark
--color-bg#ffffff#0f172a
--color-surface#f1f5f9#1e293b
--color-text#0f172a#e2e8f0
--color-muted#475569#94a3b8
--color-accent#2563eb#60a5fa
--color-border#cbd5e1#334155

Scope and fallback, live

Local override and fallback

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>

Light and dark theme from variables alone

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); }

Reading and writing variables in JavaScript

getPropertyValue and setProperty

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");

@property: typed, animatable variables

Animate a gradient angle

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; } }

Tailwind v4 @theme

Tokens become utilities and variables

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); }

Gotchas

Things that trip people up
ProblemWhyFix
var() in @media conditionMedia conditions are not evaluated per elementChange 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 initialInvalid at computed-value timeCheck the substituted value, add a fallback
Variable ignored in url()url(var(--x)) is not allowedStore 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 --bigRedeclare --big on .card too, or compute it where it is used

Scope and inheritance

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.

Invalid at computed-value time

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.

How it works

  1. Toggle the theme demo. The markup never changes, only the values of six variables.
  2. In the JavaScript tool, pick a variable, read its current value, then set a new one and watch the sample update.
  3. Copy the snippets below for tokens, fallbacks and @property.

Tips

Frequently asked questions

What is the difference between CSS variables and Sass variables?

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.

Can I use a CSS variable in a media query?

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.

Why does my variable not work?

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.

Can CSS variables be animated?

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.

Are CSS variables supported everywhere?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap