CSS Custom Properties in Practice

Updated 2026-03-04

CSS custom properties (variables) have matured from a curiosity into the backbone of design systems. They enable live theming, scoped tokens, and JavaScript–CSS bridges that were previously impossible without a preprocessor. This post covers patterns worth knowing.

The Basics

Custom properties are declared with a -- prefix and accessed via var():

1
2
3
4
5
6
7
8
9
10
11
:root {
--color-accent: #89b4fa;
--spacing-md: 1rem;
--radius: 6px;
}

.button {
background: var(--color-accent);
padding: var(--spacing-md);
border-radius: var(--radius);
}

Fallback Values

var() accepts an optional fallback — useful for progressive enhancement:

1
2
3
4
.card {
color: var(--color-text, #cdd6f4);
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
}

The fallback can itself reference another custom property:

1
2
3
.element {
color: var(--color-primary, var(--color-accent, royalblue));
}

Scoped Properties

Unlike preprocessor variables, custom properties respect the cascade and can be scoped to a subtree:

1
2
3
4
5
6
7
8
9
10
11
/* Global defaults */
:root {
--btn-bg: var(--color-surface);
--btn-text: var(--color-text);
}

/* Override inside a specific component */
.hero .button {
--btn-bg: var(--color-accent);
--btn-text: var(--color-bg);
}

This pattern lets you theme variants without adding extra classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.alert {
--alert-color: var(--color-text);
--alert-bg: var(--color-surface);
border-left: 3px solid var(--alert-color);
background: var(--alert-bg);
}

.alert--warning {
--alert-color: #f9e2af;
--alert-bg: rgba(249, 226, 175, 0.08);
}

.alert--error {
--alert-color: #f38ba8;
--alert-bg: rgba(243, 139, 168, 0.08);
}

Dark Mode Theming

Custom properties shine for dark/light theming. Define two token sets and switch them with prefers-color-scheme or a class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
:root {
--color-bg: #ffffff;
--color-text: #1a1a2e;
--color-surface: #f5f5f5;
--color-border: #e0e0e0;
}

@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1e1e2e;
--color-text: #cdd6f4;
--color-surface: #252535;
--color-border: #3a3a5c;
}
}

/* Manual toggle via a class on <html> */
:root.dark {
--color-bg: #1e1e2e;
--color-text: #cdd6f4;
--color-surface: #252535;
--color-border: #3a3a5c;
}

Computed Values and Calc

Custom properties store raw tokens and can participate in calc():

1
2
3
4
5
6
7
8
9
10
11
12
:root {
--base-size: 4; /* unitless scale base */
--sidebar-width: 240;
}

.sidebar {
width: calc(var(--sidebar-width) * 1px);
}

.spacing-2 { padding: calc(var(--base-size) * 2px); }
.spacing-4 { padding: calc(var(--base-size) * 4px); }
.spacing-8 { padding: calc(var(--base-size) * 8px); }

JavaScript Bridge

Custom properties are readable and writable via JavaScript — the only way to expose CSS values to JS without hard-coding them in two places:

1
2
3
4
5
6
7
8
9
10
11
12
// Read
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--color-accent')
.trim();

// Write (triggers CSS cascade update)
document.documentElement.style.setProperty('--sidebar-width', '320px');

// Theme switcher
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
1
2
3
4
5
6
7
8
9
:root[data-theme="catppuccin"] {
--color-accent: #cba6f7;
--color-bg: #1e1e2e;
}

:root[data-theme="nord"] {
--color-accent: #88c0d0;
--color-bg: #2e3440;
}

Animation with Custom Properties

Individual custom properties can be registered for animation with @property:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@property --progress {
syntax: '<number>';
initial-value: 0;
inherits: false;
}

.progress-ring {
--progress: 0;
stroke-dashoffset: calc(339 - (339 * var(--progress)) / 100);
transition: --progress 600ms ease;
}

.progress-ring.loaded {
--progress: 75;
}

Without @property, browsers can’t interpolate custom properties because they’re treated as opaque strings.


Design Token Naming Convention

A consistent naming scheme scales well across a design system:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
:root {
/* Primitive tokens — raw values */
--blue-400: #60a5fa;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--gray-900: #111827;
--gray-800: #1f2937;

/* Semantic tokens — role-based aliases */
--color-bg: var(--gray-900);
--color-surface: var(--gray-800);
--color-text: #f9fafb;
--color-accent: var(--blue-500);
--color-accent-hover: var(--blue-400);
--color-accent-pressed: var(--blue-600);
}

Semantic tokens are what components reference. Primitives can be swapped independently.


What Custom Properties Can’t Do (Yet)

  • They cannot be used in media query expressions: @media (max-width: var(--breakpoint-md)) doesn’t work
  • Nesting inside url()background: url(var(--img-path)) is invalid
  • They inherit by default, which occasionally causes unexpected cascading

The cascade and inheritance issues are the most common footguns. When in doubt, scope them tightly with :where() or a specific selector.