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
--accentset on.sidebaraffects 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 forcalc()chains:--size: calc(var(--base) * 2)on:rootcomputes once against:root’s--base. Re-declaring--baseon a child does not recompute--sizeon that child, because--sizeinherits 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:
- Don’t stack a
var()declaration after a static one hoping the static one survives. Use a fallback insidevar()instead. - If you need true “property-level” validation, register the property with
@property(below) — a registered property with a bad value does fall back to itsinitial-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: falsestops the value leaking down the tree, which is right for most animation knobs.initial-valueis required for every syntax except"*", and must be computationally independent (noem, novar()).
@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
:rootinvalidates style for every element that inherits it — potentially the whole document. Doing that inside ascrollorpointermovehandler 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
transformandopacityfor animations driven by variables; a variable feedingwidthortopstill 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
@propertyregistration makes interpolation as cheap as native properties.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
var(--x) with --x undefined and no fallback | Property becomes invalid at computed-value time → initial value, not the previous rule | Add a fallback: var(--x, 1rem) |
Units inside a calc() multiplier (calc(var(--n) * 1px * 1px)) | Invalid math | Multiply unitless numbers by one unit |
--size: 16px then font-size: var(--size)px | Tokens become 16px px → invalid | Store 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 child | It doesn’t; --x inherits already computed | Declare the derived value where it’s consumed |
| Transitioning an unregistered variable | Snaps, no interpolation | Register with @property |
localStorage theme applied from a bundled script | White flash on load | Inline sync script in <head> before CSS |
Using --brand-blue directly in components | Dark theme has to touch every component | Add a semantic layer (--color-accent) and theme that |
Browser support
| Feature | Chrome/Edge | Firefox | Safari |
|---|---|---|---|
Custom properties + var() | 49 | 31 | 9.1 |
@property | 85 | 128 | 16.4 |
color-scheme | 81 | 96 | 13 |
light-dark() | 123 | 120 | 17.5 |
color-mix() | 111 | 113 | 16.2 |
clamp() | 79 | 75 | 13.1 |
| Space toggle trick | 49 | 31 | 9.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.