Scope, Inheritance & Theming
Custom properties are most powerful when you think about them in terms of scope — where they're defined and how far they reach. Defining them on :root makes them global; defining them on a component makes them local. This scoping behaviour, combined with CSS inheritance, is what makes design tokens, dark mode themes, and component-level customisation possible without JavaScript.
Scope — where you define matters
/* :root scope — available everywhere */
:root {
--color-primary: #0066cc;
}
/* Component scope — only available inside .card and its descendants */
.card {
--card-bg: white;
--card-padding: 1.5rem;
background: var(--card-bg);
padding: var(--card-padding);
}
/* .card descendant uses the component-scoped variable */
.card .card__title {
color: var(--card-text, #222); /* falls back to #222 if not defined in .card scope */
}
/* .btn outside .card cannot see --card-bg */
.btn {
background: var(--card-bg); /* undefined here — resolves to invalid/initial */
}Light/Dark mode theming
The canonical use case for scoped custom properties is dark mode. Define all your color tokens on :root, then override them inside a .dark class or the prefers-color-scheme media query:
/* Light theme (default) */
:root {
--bg: #ffffff;
--bg-subtle: #f8f9fa;
--text: #1a1a2e;
--text-muted: #6c757d;
--border: #dee2e6;
--color-primary: #0066cc;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Dark theme — override the same tokens */
:root[data-theme="dark"],
.dark {
--bg: #0d1117;
--bg-subtle: #161b22;
--text: #e6edf3;
--text-muted: #8b949e;
--border: #30363d;
--color-primary: #58a6ff;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* System preference dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--text: #e6edf3;
/* ... rest of dark tokens */
}
}
/* All components just use the tokens — they don't need to know about themes */
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--bg-subtle);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}// Toggle dark mode
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.removeAttribute('data-theme'); // back to lightContextual component theming
Define a component with internal custom properties that default to global tokens, then allow a containing section to override just those properties:
/* Button component with internal tokens defaulting to global tokens */
.btn {
--btn-bg: var(--color-primary, #0066cc);
--btn-text: white;
--btn-radius: var(--border-radius, 6px);
background: var(--btn-bg);
color: var(--btn-text);
border-radius: var(--btn-radius);
padding: 0.5em 1.25em;
}
/* Inside a warning section — change the button's appearance contextually */
.warning-banner {
--color-primary: #e85d04; /* locally override the global token */
/* All .btn inside .warning-banner now become orange */
}
/* Named variant */
.btn--danger {
--btn-bg: crimson;
}
/* User inline override */<button class="btn" style="--btn-bg: gold; --btn-text: black;"> Custom button </button>
Design token hierarchies
Professional design systems layer tokens at multiple levels: primitive tokens (raw values), semantic tokens (role-based), and component tokens (component-specific):
/* Tier 1: Primitive tokens — raw values */
:root {
--blue-500: hsl(210 100% 40%);
--blue-400: hsl(210 100% 55%);
--gray-100: hsl(210 17% 95%);
--gray-900: hsl(210 20% 10%);
}
/* Tier 2: Semantic tokens — roles */
:root {
--color-primary: var(--blue-500);
--color-bg: var(--gray-100);
--color-text: var(--gray-900);
}
/* Dark mode overrides semantic tokens */
@media (prefers-color-scheme: dark) {
:root {
--color-primary: var(--blue-400);
--color-bg: var(--gray-900);
--color-text: var(--gray-100);
}
}
/* Tier 3: Component tokens — consume semantic tokens */
.btn {
--btn-bg: var(--color-primary);
--btn-text: white;
background: var(--btn-bg);
color: var(--btn-text);
}@property — typed custom properties
The @property at-rule lets you register a custom property with a type, initial value, and inheritance behaviour. This enables animations on custom properties and prevents invalid-value surprises:
/* Register a typed custom property */
@property --hue {
syntax: '<number>';
inherits: false;
initial-value: 210;
}
/* Now --hue can be animated */
.btn {
background: hsl(var(--hue) 70% 50%);
transition: --hue 0.3s ease;
}
.btn:hover {
--hue: 280; /* animates smoothly because --hue is typed as a number */
}
/* Without @property: custom properties cannot be transitioned
(the transition from 210 to 280 would jump, not interpolate) */
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes spin-gradient {
to { --gradient-angle: 360deg; }
}
.spinning {
background: linear-gradient(var(--gradient-angle), #0066cc, #00ccff);
animation: spin-gradient 3s linear infinite;
}