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

AspectSass / Less variablesCSS custom properties
EvaluatedAt build timeAt runtime by the browser
CascadeNot applicableParticipates in the cascade
InheritanceNoYes, inherited by default
Changeable at runtimeNoYes, via JS or media queries
Use in media queriesLimitedSupported 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

PitfallCauseFix
Variable resolves to nothingTypo in the nameNames are case-sensitive; check spelling
Value used in a shorthand is invalidBrowser cannot parse at computed-value timeProvide a fallback or avoid shorthand
Cannot animate smoothlyProperty has no typeRegister with @property
Variable not inherited into a shadow DOMShadow boundaryRe-declare it inside the shadow root
Inline style overrides everythingInline has highest specificityUse 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.