Skip to content
WebTricks

A practical guide to CSS custom properties (variables) and theming

CSS custom properties from syntax to theming — var() fallbacks, scoping, design tokens, dark mode with data-theme, light-dark(), @property and JS.

Custom properties — the --name values most people call CSS variables — are the mechanism behind every modern theme switch, design-token system and “tweak one number, update the whole site” workflow. They look simple, but a few rules about inheritance, fallbacks and invalid values trip up almost everyone at least once. This guide covers the working knowledge, from syntax to dark mode to @property.

Syntax and var() fallbacks

A custom property is any declaration whose name starts with two dashes. You read it back with var():

:root {
  --accent: #6c5ce7;
  --radius: 12px;
}
.button {
  background: var(--accent);
  border-radius: var(--radius);
}

Names are case-sensitive (--Accent and --accent are different properties), and the value can be almost anything — a color, a length, a list, a whole box-shadow, even plain text that only makes sense once it lands in a real property. The value is stored as raw tokens and only parsed when it is substituted, which is why you can write odd things like --gap: 4px 8px;.

The second argument of var() is a fallback, used only when the property is not defined at all on that element (not when it holds a bad value — more on that below):

.card {
  padding: var(--card-pad, 24px);
  /* fallbacks can contain commas and nested var() */
  font-family: var(--font-body, var(--font-sans, system-ui, sans-serif));
}

Everything after the first comma is the fallback, so var(--x, 1px, 2px) is valid and the fallback is the token list 1px, 2px.

Inheritance and scoping — why :root and why not

Custom properties inherit like color does. Setting one on :root (the html element) makes it visible to every descendant, which is why global tokens live there. But you can set them on any element, and the closest ancestor wins:

:root       { --accent: #6c5ce7; }
.sidebar    { --accent: #00b894; }   /* everything inside the sidebar */
.sidebar a  { color: var(--accent); } /* → green */

Two consequences worth remembering:

  • Scope is the DOM tree, not the stylesheet. A --accent set on .sidebar affects a child component even if that component’s CSS was written in a different file by a different team.
  • var() resolves on the element that uses it, not where the variable was declared. This matters for calc() chains: --size: calc(var(--base) * 2) on :root computes once against :root’s --base. Re-declaring --base on a child does not recompute --size on that child, because --size inherits as an already-computed value. If you want a child to be able to override an input, declare the derived value on the same element that consumes it.

Prefer :root for the token layer (brand colors, spacing scale, radii) and component scope for knobs that a single component owns. A global --button-padding is a smell; a .button { --pad: ... } is an API.

The “guaranteed-invalid” gotcha

With normal properties, a bad value is simply ignored and the cascade falls back to the previous valid declaration. Custom properties work differently. Because the browser cannot know what --x will be used for, it accepts any value at parse time and only checks it at computed-value time, when var() substitutes it. If the result makes no sense for the property, the declaration becomes invalid at computed-value time — and the property takes its initial (or inherited) value, not the previous rule:

:root { --pad: red; }
.box {
  padding: 16px;
  padding: var(--pad); /* red is not a length → padding becomes 0, NOT 16px */
}

The same happens when the variable is undefined and there is no fallback: var(--undefined) resolves to the guaranteed-invalid value, so color: var(--undefined) makes color inherit (its initial behavior) rather than keeping whatever you set earlier. Two takeaways:

  1. Don’t stack a var() declaration after a static one hoping the static one survives. Use a fallback inside var() instead.
  2. If you need true “property-level” validation, register the property with @property (below) — a registered property with a bad value does fall back to its initial-value.

Building a design-token layer

A token layer is just a well-named set of :root variables with a scale behind it. Keep raw values and semantic names separate so themes only override the semantic layer:

:root {
  /* raw palette — never used directly in components */
  --violet-500: oklch(58% 0.2 290);
  --violet-600: oklch(50% 0.2 290);
  --gray-50:  oklch(98% 0.005 260);
  --gray-900: oklch(20% 0.02 260);

  /* semantic tokens — what components read */
  --color-bg: var(--gray-50);
  --color-text: var(--gray-900);
  --color-accent: var(--violet-500);
  --color-accent-hover: var(--violet-600);

  /* spacing scale (4px base) */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;

  /* radii + shadows */
  --radius-sm: 6px;
  --radius-md: 12px;
  --radius-full: 9999px;
  --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.12);
}

Use rem for spacing and type so the scale respects user font-size settings; the CSS unit converter helps when translating a design file’s pixels. For the palette, build tints and shades from one hue with the tints and shades generator or get a whole harmony from the palette generator, then convert to OKLCH with the color converter — OKLCH keeps lightness perceptually even across hues, which is the point of why gradients look muddy.

Dark mode: prefers-color-scheme plus a data-theme override

The pattern that works in production has three parts: a system default, an explicit user override via a data-theme attribute, and a tiny inline script so the override applies before first paint.

/* 1. Light is the default */
:root {
  color-scheme: light;
  --color-bg: #fafafa;
  --color-text: #1a1a1a;
}

/* 2. Follow the OS unless the user explicitly chose light */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --color-bg: #121214;
    --color-text: #ededf0;
  }
}

/* 3. Explicit dark override always wins */
:root[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: #121214;
  --color-text: #ededf0;
}

Because components only read --color-bg and --color-text, nothing else changes when the theme does. The :not([data-theme="light"]) guard is what lets a user on a dark OS pick light mode and have it stick.

No-flash script. If the saved preference is applied from a deferred bundle, dark-mode users see a white flash on every load. Put a two-line synchronous script in <head>, before the stylesheet, that sets the attribute from localStorage:

<script>
  try {
    const t = localStorage.getItem('theme');
    if (t === 'dark' || t === 'light') {
      document.documentElement.dataset.theme = t;
    }
  } catch {}
</script>

The toggle itself just writes the same attribute and key:

function setTheme(theme) {
  document.documentElement.dataset.theme = theme;
  try { localStorage.setItem('theme', theme); } catch {}
}

To “reset to system”, remove the attribute and the storage key — the media query takes over again.

light-dark() and color-scheme

Declaring color-scheme is not just for your own variables: it switches the browser’s built-in UI (form controls, scrollbars, the default canvas color) to the matching palette. It also unlocks the light-dark() function, which picks one of two values based on the used color scheme — no media query required:

:root { color-scheme: light dark; }      /* support both; follow the OS */
:root[data-theme="dark"]  { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }

:root {
  --color-bg:   light-dark(#fafafa, #121214);
  --color-text: light-dark(#1a1a1a, #ededf0);
}

This collapses the three-block pattern above into one block per token, and the data-theme override still works because it changes color-scheme, which light-dark() reads. Support shipped in all major engines in 2024 (Chrome 123, Firefox 120, Safari 17.5); it only accepts colors, so non-color tokens (shadows with offsets, images) still need a media query or attribute selector.

@property — registered, typed, animatable

By default a custom property has no type, so the browser cannot interpolate it: transitioning --angle from 0deg to 360deg just snaps. @property registers the property with a syntax, an inheritance flag and an initial value:

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

.ring {
  --angle: 0deg;
  background: conic-gradient(from var(--angle), #6c5ce7, #00b894, #6c5ce7);
  transition: --angle 600ms ease;
}
.ring:hover { --angle: 360deg; }

Now --angle animates like any numeric property, which is how the animated gradient border rotates smoothly — see the transitions and animations guide for timing details. Common syntax values: "<length>", "<color>", "<number>", "<percentage>", "<length-percentage>", "<angle>", and "*" for untyped. Three things registration changes:

  • Typed validation. A value that does not match the syntax is rejected and the property falls back to initial-value — the sane behavior the unregistered case lacks.
  • inherits: false stops the value leaking down the tree, which is right for most animation knobs.
  • initial-value is required for every syntax except "*", and must be computationally independent (no em, no var()).

@property has been in Chrome/Edge since 85 and Safari since 16.4, and landed in Firefox 128 (July 2024). The JS equivalent is CSS.registerProperty({ name, syntax, inherits, initialValue }).

Fluid type and space with calc() and clamp()

Custom properties shine inside math functions. A fluid type scale that grows with the viewport between two bounds:

:root {
  --fluid-min: 1rem;     /* at 320px viewport */
  --fluid-max: 1.25rem;  /* at 1280px viewport */
  --fluid-slope: calc((1.25 - 1) / (1280 - 320));
  --step-0: clamp(
    var(--fluid-min),
    calc(var(--fluid-min) + (100vw - 320px) * var(--fluid-slope)),
    var(--fluid-max)
  );
  --step-1: calc(var(--step-0) * 1.25);
  --step-2: calc(var(--step-0) * 1.563);
}
h1 { font-size: var(--step-2); }

Numbers and lengths can be multiplied (1rem * 1.25) but two lengths cannot, and division only takes a unitless divisor — so keep ratios as plain numbers and attach units once. The same idea gives a fluid spacing token:

:root { --space-section: clamp(2rem, 4vw + 1rem, 6rem); }

Custom properties in JavaScript

Reading a variable goes through getComputedStyle; writing goes through style.setProperty. Both use the full name with dashes:

const root = document.documentElement;

// read (returns a string, often with leading whitespace → trim it)
const accent = getComputedStyle(root).getPropertyValue('--color-accent').trim();

// write — on :root for global, or on any element for local scope
root.style.setProperty('--color-accent', '#ff7675');

// remove an inline override and let the stylesheet value return
root.style.removeProperty('--color-accent');

getPropertyValue returns the computed value: for a registered "<color>" property that means a normalized color; for an unregistered one it is the raw token text you wrote. If you set a value on an element that also has a transition for that property (via @property), the change animates.

This is exactly how the interactive snippet playgrounds on this site work — each slider calls setProperty on a :root variable the snippet’s CSS reads.

Component APIs via custom properties

Expose knobs instead of modifier classes. The component declares its defaults on itself; consumers set the variable from outside:

.btn {
  --btn-bg: var(--color-accent);
  --btn-fg: white;
  --btn-radius: var(--radius-md);
  --btn-pad-x: var(--space-4);

  background: var(--btn-bg);
  color: var(--btn-fg);
  border-radius: var(--btn-radius);
  padding: var(--space-2) var(--btn-pad-x);
}
.btn:hover { background: color-mix(in oklch, var(--btn-bg), black 12%); }

/* consumer */
.hero .btn { --btn-bg: #00b894; --btn-radius: var(--radius-full); }

Hover and focus states derive from the same input with color-mix(), so a consumer overriding --btn-bg gets correct states for free. The hover lift card, liquid glass card and CSS toggle switch snippets all follow this shape — every tweakable value is a :root custom property, which is what makes their sliders possible.

A subtlety: write defaults with .btn { --btn-bg: ... }, not on :root. A default on :root would be overridden by inheritance from any ancestor that sets --btn-bg for a different reason, whereas a default on the component itself can only be overridden by a rule that targets the component — which is the intent.

The space toggle: conditional values without @if

CSS has no if() in widely-shipped browsers yet, but there is a well-known trick. A custom property whose value is a single space is valid, and substituting it into a shorthand contributes nothing — while var(--undefined, fallback) contributes the fallback. Flip the switch by setting the variable to a space (ON) or to initial (OFF):

.card {
  --on: initial; /* OFF: makes var(--on) fall back */
  --off: ;       /* a lone space */
  --featured: var(--off);

  border: 1px solid var(--featured, #e0e0e0); /* plain when OFF */
  box-shadow: var(--featured, none);
}
.card.is-featured {
  --featured: var(--on);
}

Read it as: when --featured is initial (undefined), every var(--featured, X) yields X; when it is a space, every one of them yields nothing — so you pair each conditional declaration with a sibling that supplies the “on” value. It is clever rather than readable; use it for one-off flags in a snippet, not as the backbone of a design system. Native if() with style() queries is arriving in Chrome 137+ and will make this obsolete.

Performance notes

Custom properties are cheap to read. They are not free to change:

  • Changing a variable on :root invalidates style for every element that inherits it — potentially the whole document. Doing that inside a scroll or pointermove handler produces a full style recalc per frame. Set the variable on the smallest element that needs it (the parallax layer, the cursor-follower), not the root.
  • Prefer transform and opacity for animations driven by variables; a variable feeding width or top still triggers layout.
  • Deep var() chains (--a → --b → --c → --d) are resolved per element and cost a little during style recalc. Three levels (raw → semantic → component) is plenty.
  • The old advice that custom properties are “slow” came from early implementations; today they are a non-issue at normal scale, and @property registration makes interpolation as cheap as native properties.

Common mistakes

MistakeWhat happensFix
var(--x) with --x undefined and no fallbackProperty becomes invalid at computed-value time → initial value, not the previous ruleAdd a fallback: var(--x, 1rem)
Units inside a calc() multiplier (calc(var(--n) * 1px * 1px))Invalid mathMultiply unitless numbers by one unit
--size: 16px then font-size: var(--size)pxTokens become 16px px → invalidStore the unit with the value, or store unitless and multiply
Expecting --x: calc(var(--y) * 2) on :root to update when --y is set on a childIt doesn’t; --x inherits already computedDeclare the derived value where it’s consumed
Transitioning an unregistered variableSnaps, no interpolationRegister with @property
localStorage theme applied from a bundled scriptWhite flash on loadInline sync script in <head> before CSS
Using --brand-blue directly in componentsDark theme has to touch every componentAdd a semantic layer (--color-accent) and theme that

Browser support

FeatureChrome/EdgeFirefoxSafari
Custom properties + var()49319.1
@property8512816.4
color-scheme819613
light-dark()12312017.5
color-mix()11111316.2
clamp()797513.1
Space toggle trick49319.1

In practice: the base feature set is universal; @property, light-dark() and color-mix() are safe for evergreen browsers from mid-2024 onward, and each degrades gracefully if you give a static value first and the variable-based one second (the second declaration is ignored where unsupported).

FAQ

What is the difference between CSS custom properties and Sass variables?

Sass variables are replaced at compile time and vanish from the output. CSS custom properties exist in the browser: they cascade, inherit, can be changed by a media query, a data-theme attribute or JavaScript at runtime, and can be animated when registered with @property. Use Sass variables for build-time constants and custom properties for anything a theme or user can change.

Why does my fallback not apply when the variable has a wrong value?

The var() fallback only kicks in when the property is undefined. If it is defined with a value that is wrong for the context, the whole declaration becomes invalid at computed-value time and the property resets to its initial or inherited value. Register the property with @property and a typed syntax to get real validation with an initial-value fallback.

Should I define variables on :root or on html or body?

:root and html are the same element; :root just has higher specificity as a pseudo-class and is the convention. Avoid body for tokens: anything rendered outside body (rare, but ::backdrop on a dialog is one case) will not see them.

How do I animate a CSS variable?

Register it with @property and a numeric or color syntax, then transition or animate it like any property: transition: --angle 600ms. Without registration the browser treats the value as opaque text and jumps straight to the end state.

Can I use a custom property in a media query?

No. @media (min-width: var(--bp)) is invalid because media queries are evaluated before the cascade exists. Use container style queries (@container style(--variant: compact)) to branch on a variable’s value, or keep breakpoints as Sass or PostCSS constants.

Building a theme as you read? Keep the color converter and tints and shades generator open, browse all snippets or subscribe via RSS.