CSSScope, Inheritance & Theming

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

CSS
/* :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:

CSS
/* 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);
}

JS
// Toggle dark mode
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.removeAttribute('data-theme'); // back to light
Contextual component theming

Define a component with internal custom properties that default to global tokens, then allow a containing section to override just those properties:

CSS
/* 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 */

HTML
<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):

CSS
/* 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);
}
The three-tier token hierarchy (primitives → semantic → component) is how Tailwind v3, Material Design, and Radix UI structure their design tokens — it separates color values from intent
Primitive tokens are just raw values — a palette of blues. Semantic tokens map those values to roles (primary, background, text). Component tokens apply roles to specific components. This separation means you can change the entire colour scheme by only changing the semantic layer, and dark mode only needs to override semantic tokens, not every component.
@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:

CSS
/* 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;
}
Next
Building beautiful text — the fundamentals of web typography: [Typography Overview](/css/typography-intro).