CSS Custom Properties (Variables) Cheat Sheet
Covers declaring and scoping CSS custom properties, fallback values, reading/writing them from JavaScript, and typed properties with @property.
Basic Declaration & Usage
Define once, reuse everywhere, with a fallback.
:root { --brand-color: #6366f1; --spacing-unit: 8px; --max-width: 1200px;}.card { color: var(--brand-color); padding: calc(var(--spacing-unit) * 2); /* Fallback is used only if --unknown is not defined at all */ border-color: var(--unknown, #ccc);}
Scoping & Theming
Custom properties cascade and can be overridden per subtree.
/* Global tokens */:root { --color-primary: #2563eb;}/* Component-scoped override, only affects .card and its descendants */.card { --color-primary: #059669; background: var(--color-primary);}.card--dark { --color-primary: #10b981;}
Reading & Writing from JavaScript
Custom properties are live values in the DOM.
const root = document.documentElement;// Read a custom property (returns a string; trim whitespace)const color = getComputedStyle(root).getPropertyValue('--color-primary').trim();// Set/update a custom property at runtimeroot.style.setProperty('--color-primary', '#f43f5e');// Remove an inline override, falling back to the cascaded valueroot.style.removeProperty('--color-primary');
@property (Typed Custom Properties)
Register a syntax so the value can be animated smoothly.
@property --progress { syntax: '<percentage>'; inherits: false; initial-value: 0%;}.bar { width: var(--progress); transition: --progress 0.3s ease; /* animatable because it's typed */}.bar.loaded { --progress: 75%;}
Key Facts & Gotchas
Behavior that trips people up coming from Sass variables.
- Inheritance- Custom properties inherit by default, and follow normal cascade/specificity rules
- Case-sensitive- --Color and --color are two different custom properties
- var() fallback- var(--x, fallback) only applies when --x is unset, not when its value is invalid
- Runtime updates- Unlike Sass variables, custom properties are live and can be read/changed with JavaScript
- No custom properties in media queries- var() cannot currently be used inside @media conditions
The Space Toggle Pattern for Boolean Styling
Toggle a custom property between empty and a value to switch styles without JS or extra classes.
.alert { /* Undefined by default: var() falls through to its fallback (0) */ --is-active: ; border-color: var(--is-active, ) red var(--is-active, ) transparent;}.alert.active { /* A single space is a valid token, so the fallback is skipped */ --is-active: initial;}/* Common variant: flip a numeric multiplier instead of a token */.card { --hidden: 0; opacity: var(--hidden);}.card[data-open='true'] { --hidden: 1;}
Combining var() with clamp() and Container Units
Fluid, container-relative sizing driven by a single custom property token.
.panel { container-type: inline-size;}.panel-title { --min-size: 1rem; --max-size: 2rem; /* Scales with the container's inline size, clamped between tokens */ font-size: clamp(var(--min-size), 4cqi, var(--max-size));}/* Custom properties can also hold calc() expressions themselves */:root { --gutter: clamp(1rem, 2vw, 2.5rem);}.layout { padding-inline: var(--gutter); gap: var(--gutter);}
@property inherits: false for Component Isolation
Prevents a themed value from leaking into nested components that redefine it.
@property --card-elevation { syntax: '<number>'; inherits: false; /* each element must set its own, no cascading down */ initial-value: 1;}.card { --card-elevation: 3; box-shadow: 0 calc(var(--card-elevation) * 2px) calc(var(--card-elevation) * 4px) rgb(0 0 0 / 0.2);}/* Nested card resets to the registered initial-value (1), not the parent's 3, because inherits: false was declared */.card .card { box-shadow: 0 2px 4px rgb(0 0 0 / 0.2);}
Animating Custom Properties & Observing Changes
requestAnimationFrame-driven updates and a ResizeObserver-fed property.
// Smoothly ramp a custom property that isn't @property-registered// by animating it manually (no native transition without @property)function animateVar(el, prop, from, to, duration = 300) { const start = performance.now(); function step(now) { const t = Math.min((now - start) / duration, 1); el.style.setProperty(prop, `${from + (to - from) * t}`); if (t < 1) requestAnimationFrame(step); } requestAnimationFrame(step);}// Feed layout measurements into CSS via a custom propertyconst ro = new ResizeObserver(([entry]) => { document.documentElement.style.setProperty( '--viewport-vh', `${entry.contentRect.height}px` );});ro.observe(document.body);
Advanced Gotchas & Interop
Edge cases that matter once custom properties drive real theming systems.
- Space toggle trick- Setting a property to a lone space token (vs. unset) lets var(--x, fallback) act as a CSS-only boolean switch
- Invalid at computed-value time (IACVT)- An @property-typed variable given an invalid value resets to its initial-value, not the previous valid one
- Custom properties in shadow DOM- Inherited custom properties cross shadow boundaries (they pierce encapsulation); shadow parts do not
- Cascade layers interaction- var() resolves against the cascaded value at use time, so @layer ordering affects which declaration a variable reads
- No fallback on invalid value- var(--x, fallback) only triggers when --x is unset entirely; an invalid (but set) value makes the whole declaration invalid instead
- Registered inheritance default- Without @property, all custom properties inherit by default regardless of naming; @property lets you opt a token out via inherits: false
Use @property to declare a syntax and initial value for custom properties you want to animate — the browser only interpolates typed values like <percentage> or <color> smoothly; plain untyped custom properties jump instantly with no interpolation.