CSS Variables
Custom properties, :root scope, dynamic theming, fallback values, and JavaScript integration — the key to maintainable, dynamic stylesheets.
What Are CSS Custom Properties
CSS Custom Properties (commonly called CSS Variables) let you store values in named tokens that can be reused throughout your stylesheet. Unlike preprocessor variables (Sass $color, Less @color), CSS variables are live — they exist in the browser, cascade like any other property, inherit down the DOM tree, and can be changed at runtime with JavaScript.
/* Declare a custom property */ :root { --primary: #6c8cff; } /* Use it anywhere */ .button { background: var(--primary); }
CSS Variables vs Preprocessor Variables
| Feature | CSS Variables | Sass / Less |
|---|---|---|
| Runs in | The browser (live) | Build step (compiled away) |
| Cascade & inherit | Yes | No |
| Change at runtime | Yes (JS, media queries, hover) | No |
| Scoped to selectors | Yes — any selector | Global or block scope only |
| DevTools inspection | Visible and editable | Compiled out |
| Fallback values | Built-in with var() | Requires mixin logic |
Declaring Variables
A custom property starts with two dashes (--) followed by a name. The name is case-sensitive and can contain letters, numbers, hyphens, and underscores.
/* Valid declarations */ .element { --color-primary: #6c8cff; --font-size-lg: 1.25rem; --spacing-4: 16px; --myComponent-bg: hsl(220, 15%, 12%); --border_width: 2px; --123: red; /* valid but not recommended */ } /* Case-sensitive! These are TWO different variables */ .box { --myColor: red; --mycolor: blue; /* different variable */ }
Where You Can Declare
Variables can be declared in any CSS selector — not just :root. They follow the same cascade and specificity rules as regular properties.
/* Global scope */ :root { --gap: 16px; } /* Element scope */ .card { --gap: 24px; } /* State scope */ .card:hover { --gap: 32px; } /* Media query scope */ @media (min-width: 768px) { :root { --gap: 24px; } } /* Inline style */ /* <div style="--gap: 40px"> */
The :root Scope
The :root pseudo-class matches the document's root element (<html> in HTML). Because every element in the page inherits from it, variables declared on :root are effectively global.
:root and html both select the same element, but :root has higher specificity (0,1,0 vs 0,0,1). By convention, global variables go on :root.
Organized :root Block
:root { /* ── Colors ── */ --color-primary: #6c8cff; --color-secondary: #a78bfa; --color-success: #2ecc71; --color-danger: #e74c3c; --color-text: #e2e4e9; --color-muted: #9ca3af; /* ── Spacing ── */ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --space-8: 32px; /* ── Typography ── */ --font-body: 'Inter', system-ui, sans-serif; --font-code: 'Fira Code', monospace; --font-size-sm: 0.875rem; --font-size-base: 1rem; --font-size-lg: 1.25rem; --font-size-xl: 1.5rem; /* ── Layout ── */ --radius: 8px; --sidebar-w: 280px; --header-h: 58px; --transition: 0.25s ease; }
Using var()
The var() function retrieves the value of a custom property. It can be used anywhere a value is expected in a CSS property.
.card { /* Simple usage */ color: var(--color-text); padding: var(--space-4); border-radius: var(--radius); /* Inside other functions */ background: hsl(var(--hue), 80%, 60%); width: calc(100% - var(--sidebar-w)); transition: all var(--transition); font-family: var(--font-body); /* In shorthand properties */ margin: var(--space-4) var(--space-6); border: 1px solid var(--border-color); }
Using in calc()
:root { --base-size: 16px; --scale: 1.25; } h1 { font-size: calc(var(--base-size) * var(--scale) * var(--scale) * var(--scale)); } h2 { font-size: calc(var(--base-size) * var(--scale) * var(--scale)); } h3 { font-size: calc(var(--base-size) * var(--scale)); }
Heading 1 — scale^3
Heading 2 — scale^2
Heading 3 — scale^1
Body text — base size
Fallback Values
The second argument to var() is a fallback value used when the variable is not defined or is invalid.
/* Simple fallback */ color: var(--text-color, #333); /* Fallback to another variable */ color: var(--link-color, var(--primary, blue)); /* Fallback with commas (entire rest is the fallback) */ font-family: var(--font-stack, Inter, Helvetica, sans-serif); /* The comma separates variable name from fallback */ /* Everything after the first comma is the fallback */ background: var(--bg, linear-gradient(to right, #6c8cff, #a78bfa));
var(--font, Inter, sans-serif) has a fallback of Inter, sans-serif (including the comma). This is actually useful for font stacks!
Scope & Inheritance
CSS variables follow the same cascade and inheritance rules as regular CSS properties. A variable set on a parent is available to all its descendants. A variable set on a more specific selector overrides the inherited value for that subtree.
:root { --color: blue; } .sidebar { --color: green; } /* overrides for .sidebar subtree */ .alert { --color: red; } /* overrides for .alert subtree */ /* All these use var(--color) but get different values */ .text { color: var(--color); }
.parent and also on .parent .child, the child's declaration wins for the child and its descendants. This is standard CSS cascade behavior applied to custom properties.
Dynamic Theming
CSS variables make theming trivial. Define all your design tokens as variables, then swap them by changing a single attribute or class on the root element.
/* Dark theme (default) */ [data-theme="dark"] { --bg: #0f1117; --bg-card: #1a1d27; --text: #e2e4e9; --border: #2a2d3a; --accent: #6c8cff; } /* Light theme */ [data-theme="light"] { --bg: #f5f6fa; --bg-card: #ffffff; --text: #1a1d27; --border: #e0e2e9; --accent: #4a6cf7; } /* Components just use the variables */ .card { background: var(--bg-card); color: var(--text); border: 1px solid var(--border); }
// Toggle theme function toggleTheme() { const html = document.documentElement; html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', html.dataset.theme); }
Themed Card
This card switches between dark and light by changing CSS variables on the container. Click the button!
Component-Level Variables
Scope variables to individual components to create self-contained, configurable UI elements. This is the pattern behind many modern CSS component libraries.
.btn { /* Component-level defaults */ --btn-bg: #6c8cff; --btn-text: #fff; --btn-radius: 6px; --btn-padding: 8px 20px; background: var(--btn-bg); color: var(--btn-text); border-radius: var(--btn-radius); padding: var(--btn-padding); border: none; cursor: pointer; font-weight: 600; } /* Variants just override the variables */ .btn--danger { --btn-bg: #e74c3c; } .btn--success { --btn-bg: #2ecc71; } .btn--outline { --btn-bg: transparent; --btn-text: #6c8cff; border: 2px solid var(--btn-text); } .btn--lg { --btn-padding: 12px 32px; --btn-radius: 10px; }
Variables with calc()
Combining variables with calc() lets you build dynamic spacing, sizing, and layout systems from a set of base tokens.
:root { --space-unit: 8px; } .box { padding: calc(var(--space-unit) * 2); /* 16px */ margin: calc(var(--space-unit) * 3); /* 24px */ gap: calc(var(--space-unit) * 1.5); /* 12px */ }
Variables & Media Queries
You can redefine variable values inside media queries to create responsive designs without duplicating properties.
:root { --container-padding: 16px; --font-size-hero: 1.8rem; --grid-columns: 1; } @media (min-width: 768px) { :root { --container-padding: 32px; --font-size-hero: 2.5rem; --grid-columns: 2; } } @media (min-width: 1200px) { :root { --container-padding: 48px; --font-size-hero: 3.5rem; --grid-columns: 3; } } /* Components automatically respond */ .container { padding: var(--container-padding); } .hero h1 { font-size: var(--font-size-hero); } .grid { grid-template-columns: repeat(var(--grid-columns), 1fr); }
@media (min-width: var(--breakpoint)). The condition is evaluated before custom properties are resolved. Use environment variables (env()) or preprocessor variables for that.
JavaScript Integration
One of the most powerful features of CSS variables is that JavaScript can read and write them at runtime, enabling dynamic, interactive styling.
Reading Variables
// Read from :root / computed style const root = document.documentElement; const accent = getComputedStyle(root) .getPropertyValue('--accent'); // Read from a specific element const card = document.querySelector('.card'); const cardBg = getComputedStyle(card) .getPropertyValue('--card-bg');
Writing Variables
// Set on :root (affects everything) document.documentElement.style .setProperty('--accent', '#ff6b6b'); // Set on a specific element card.style.setProperty('--card-bg', '#2a2d3a'); // Remove (revert to inherited/default) card.style.removeProperty('--card-bg');
Mouse-Tracking Example
document.addEventListener('mousemove', (e) => { document.documentElement.style.setProperty( '--mouse-x', e.clientX + 'px' ); document.documentElement.style.setProperty( '--mouse-y', e.clientY + 'px' ); }); /* CSS */ .spotlight { background: radial-gradient( circle at var(--mouse-x) var(--mouse-y), rgba(108,140,255,.2), transparent 300px ); }
Animation & @property
By default, CSS variables cannot be animated with transitions or keyframes because the browser doesn't know what type of value they hold. The @property rule fixes this by registering a custom property with a specific syntax (type).
The Problem
/* This WON'T animate smoothly — it snaps */ :root { --hue: 0; } .box { background: hsl(var(--hue), 80%, 60%); transition: --hue 1s; /* browser ignores this */ } .box:hover { --hue: 180; }
The Solution: @property
@property --hue { syntax: '<number>'; initial-value: 0; inherits: false; } .box { background: hsl(var(--hue), 80%, 60%); transition: --hue 1s ease; /* NOW it works! */ } .box:hover { --hue: 180; }
@property Syntax Types
| Syntax String | Accepts | Example |
|---|---|---|
<number> | Any number | 42, 3.14 |
<integer> | Whole numbers | 1, 200 |
<length> | Length values | 16px, 2rem |
<percentage> | Percentages | 50% |
<color> | Color values | #ff0, rgb(…) |
<angle> | Angles | 45deg, 1turn |
<length-percentage> | Length or % | 10px, 50% |
* | Any value | (no animation) |
The conic-gradient above animates smoothly because --ap-angle is registered with @property as <angle>.
Practical Patterns
Design Token System
:root { /* ── Primitive tokens ── */ --blue-50: #eff6ff; --blue-500: #3b82f6; --blue-900: #1e3a5f; --gray-100: #f3f4f6; --gray-800: #1f2937; /* ── Semantic tokens (reference primitives) ── */ --color-primary: var(--blue-500); --color-bg: var(--gray-100); --color-text: var(--gray-800); /* ── Component tokens (reference semantic) ── */ --btn-bg: var(--color-primary); --card-bg: var(--color-bg); --heading-color: var(--color-text); }
Z-Index Management
:root { --z-dropdown: 100; --z-sticky: 200; --z-overlay: 300; --z-modal: 400; --z-toast: 500; --z-tooltip: 600; } .modal { z-index: var(--z-modal); } .tooltip { z-index: var(--z-tooltip); }
Responsive Typography Scale
:root { --text-xs: clamp(0.7rem, 0.65rem + 0.25vw, 0.8rem); --text-sm: clamp(0.8rem, 0.75rem + 0.3vw, 0.9rem); --text-base: clamp(0.9rem, 0.85rem + 0.35vw, 1.05rem); --text-lg: clamp(1.1rem, 1rem + 0.5vw, 1.35rem); --text-xl: clamp(1.3rem, 1.1rem + 0.8vw, 1.8rem); --text-2xl: clamp(1.6rem, 1.3rem + 1.2vw, 2.5rem); --text-3xl: clamp(2rem, 1.5rem + 2vw, 3.5rem); }
Color Palette from HSL
:root { --hue: 220; --primary-50: hsl(var(--hue), 90%, 95%); --primary-100: hsl(var(--hue), 85%, 85%); --primary-300: hsl(var(--hue), 80%, 70%); --primary-500: hsl(var(--hue), 75%, 55%); --primary-700: hsl(var(--hue), 70%, 40%); --primary-900: hsl(var(--hue), 65%, 25%); } /* Change ONE variable to shift the entire palette! */
Gotchas
var(--prop-name): value; does NOT work. Variables can only replace values, not property names or selectors.
--a references --b and --b references --a, both become invalid at computed-value time. The browser treats them as if they were never set and falls back to the property's initial value (not the var() fallback).
--size: red; width: var(--size)), the property gets the initial value — not the inherit or fallback. This is different from a normal syntax error!
@media (min-width: var(--bp)) does NOT work. Media conditions are evaluated before custom properties are resolved. Use preprocessor variables or hard-code breakpoints.
--myColor and --mycolor are different variables. This is unlike regular CSS properties which are case-insensitive. Stick to a consistent naming convention (e.g., kebab-case).
--empty: ; (with a space after the colon) is a valid declaration. This can cause hard-to-debug issues where the variable exists but holds whitespace.
Pro Tips
- By category:
--color-primary,--font-size-lg,--space-4 - By component:
--btn-bg,--card-radius,--nav-height - Layered tokens: primitive → semantic → component (like the design token pattern above)
--columns: 3; grid-template-columns: repeat(var(--columns), 1fr); — This makes it easy to change via JS without worrying about units.
/* * .alert Component API: * --alert-bg Background color (default: #fff3cd) * --alert-border Border color (default: #ffc107) * --alert-text Text color (default: #664d03) * --alert-radius Corner radius (default: 8px) */ .alert { background: var(--alert-bg, #fff3cd); border: 1px solid var(--alert-border, #ffc107); color: var(--alert-text, #664d03); border-radius: var(--alert-radius, 8px); padding: 12px 16px; }
inherit. Even hundreds of variables have negligible performance impact. The only caveat: animating variables without @property triggers a full style recalculation.
@property has more limited support (Chrome 85+, Safari 15.4+, Firefox 128+). Use @supports for progressive enhancement.