Overview
CSS custom properties (often called CSS variables) let you store values once and reuse them throughout a stylesheet. Unlike preprocessor variables, they are live, cascade-aware, and can be read and modified at runtime by JavaScript. This tutorial covers the syntax, theming patterns, and common pitfalls.
Custom Properties vs Preprocessor Variables
| Aspect | Sass / Less variables | CSS custom properties |
|---|---|---|
| Evaluated | At build time | At runtime by the browser |
| Cascade | Not applicable | Participates in the cascade |
| Inheritance | No | Yes, inherited by default |
| Changeable at runtime | No | Yes, via JS or media queries |
| Use in media queries | Limited | Supported inside rules |
Basic Syntax
:root {
--brand-color: #2563eb;
--spacing-unit: 8px;
--font-stack: "Inter", system-ui, sans-serif;
}
.button {
background-color: var(--brand-color);
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
font-family: var(--font-stack);
}
Custom properties must start with --. They are read with the var() function.
Fallback Values
.card {
color: var(--card-text, #333);
border: 1px solid var(--card-border, transparent);
}
If the variable is undefined, the fallback is used. Fallbacks can nest:
color: var(--primary, var(--fallback, black));
Scope and Inheritance
Custom properties follow the cascade. Defining one on :root makes it global; defining it on a selector limits its scope.
:root {
--accent: blue;
}
.danger-zone {
--accent: red; /* overrides within this subtree */
}
.danger-zone button {
background: var(--accent); /* red */
}
Light and Dark Theme
:root {
--bg: #ffffff;
--text: #111827;
--border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--text: #f1f5f9;
--border: #1e293b;
}
}
body {
background: var(--bg);
color: var(--text);
}
.card {
border: 1px solid var(--border);
}
The markup never changes; only the variable values swap. This is far cleaner than duplicating every rule inside the media query.
Manual Theme Toggle
:root[data-theme="dark"] {
--bg: #0f172a;
--text: #f1f5f9;
}
document.documentElement.dataset.theme = "dark";
Responsive Values
:root {
--container-padding: 16px;
}
@media (min-width: 768px) {
:root {
--container-padding: 32px;
}
}
.container {
padding-inline: var(--container-padding);
}
Changing the variable in one place updates every rule that uses it.
Component API with Custom Properties
Expose variables as a component's public interface, with sensible defaults.
.button {
--button-bg: var(--brand-color, #2563eb);
--button-fg: white;
--button-radius: 6px;
background: var(--button-bg);
color: var(--button-fg);
border-radius: var(--button-radius);
padding: 0.5rem 1rem;
border: none;
}
Consumers override without touching the component's internals:
.button.danger {
--button-bg: #dc2626;
--button-radius: 0;
}
Dynamic Values and Math
.progress {
--percent: 0;
}
.progress-bar {
width: calc(var(--percent) * 1%);
transition: width 0.3s ease;
}
element.style.setProperty("--percent", 75);
Because custom properties are untyped at parse time, calc() and similar functions can operate on them without issue.
Reading and Setting from JavaScript
// Read
const rootStyles = getComputedStyle(document.documentElement);
const brand = rootStyles.getPropertyValue("--brand-color").trim();
// Write on :root
document.documentElement.style.setProperty("--brand-color", "#7c3aed");
// Write on a specific element
card.style.setProperty("--card-padding", "24px");
// Remove
card.style.removeProperty("--card-padding");
Animation with @property
By default, the browser treats a custom property as an untyped token, so it cannot be smoothly interpolated. The @property rule gives it a type.
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.spinner {
--angle: 0deg;
transform: rotate(var(--angle));
animation: spin 1s linear infinite;
}
@keyframes spin {
to { --angle: 360deg; }
}
Without @property, --angle would jump from 0 to 360 instead of rotating smoothly.
Common Pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Variable resolves to nothing | Typo in the name | Names are case-sensitive; check spelling |
| Value used in a shorthand is invalid | Browser cannot parse at computed-value time | Provide a fallback or avoid shorthand |
| Cannot animate smoothly | Property has no type | Register with @property |
| Variable not inherited into a shadow DOM | Shadow boundary | Re-declare it inside the shadow root |
| Inline style overrides everything | Inline has highest specificity | Use a class-based override or a CSS layer |
Best Practices
- Define design tokens at
:root: colors, spacing, typography, radii, shadows. - Prefix component-scoped variables with the component name, e.g.
--card-padding. - Always provide sensible defaults so components work without configuration.
- Use semantic names (
--color-danger) over literal ones (--color-red). - Keep the token layer thin; do not create a variable for every possible value.
- Document the public variables a component exposes.
