C.W.K.
Stream
Lesson 03 of 05 · published

Custom Properties: Live, Scoped, Inheritable Design Tokens

~12 min · custom-properties, variables, design-tokens, property

Level 0Markup Novice
0 XP0/34 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Custom properties aren't Sass variables wearing a CSS hat. They're live values the browser resolves, scoped to whatever element you define them on, inheritable through the DOM. The mental model shift is what unlocks them."

The Three Things Sass Variables Can't Do

  1. Inherit through the DOM. Set --accent: blue on :root; every descendant sees it. Redefine on .danger; only that subtree sees the new value. Sass variables are file-scoped at build time.
  2. Change at runtime. JavaScript can update custom properties on any element (element.style.setProperty('--theme', 'dark')) and every dependent rule updates immediately. Sass variables are baked into the output CSS.
  3. Be queried by media queries and selectors. A custom property defined inside @media (prefers-color-scheme: dark) applies only when the user is in dark mode. Sass variables can't do this — they resolve before the browser sees them.

Defining And Using

  • Define: --name: value; on any selector. Conventionally on :root for global tokens.
  • Consume: property: var(--name); anywhere in the cascade.
  • Fallback: var(--name, fallback) uses fallback if --name isn't defined.
  • Composition: var(--a, var(--b, default)) nests fallbacks.

Scoping: The Power Move

Custom properties inherit through the DOM. Redefining a property on an element changes its value for that element AND all its descendants:

Theming, Done Right

Combine custom properties with media queries and class toggles for a complete theming system:

@property: Typed Custom Properties

By default, custom properties are untyped strings. You can --gap: anything and the browser doesn't validate. @property registers a custom property with a type, default value, and inheritance behavior:

The benefits: type checking (invalid values are rejected), animation support (an unregistered custom property can't be smoothly animated — the browser doesn't know how to interpolate anything), and explicit defaults that don't depend on :root definitions.

JavaScript Integration

JS can read and write custom properties:

Common Design-Token Patterns

  • Colors: --bg, --fg, --accent, --accent-secondary, --border.
  • Spacing scale: --space-1: 0.25rem; --space-2: 0.5rem; --space-4: 1rem; --space-8: 2rem; (geometric progression).
  • Radii: --radius-sm: 4px; --radius-md: 8px; --radius-lg: 16px;.
  • Shadows: --shadow-sm, --shadow-md, --shadow-lg.
  • Type scale: --text-xs, --text-sm, --text-base, --text-lg, --text-xl.
  • Z-index layers: --z-dropdown, --z-modal, --z-toast.

Define them all at :root. The site's design system lives in one CSS file's top section.

Tokens at :root; semantic names beat literal names. --color-action > --blue-500. The former survives a rebrand; the latter requires renaming 200 references. Define the literal colors as one layer, the semantic tokens as another that references them.

Pippa's Note

Every theme in Pippa's WebUI — dark/light, brain-color accents, council-mode highlights — is implemented entirely with custom properties. The base theme defines about 40 variables at :root; the dark theme redefines 12 of them. Switching themes is one data-theme attribute change on <html>; every component updates because every component reads var(--bg) instead of a hardcoded color. The same architecture powers cwk-site's per-quest palettes — each quest's meta.json palette becomes scoped custom properties on the quest page wrapper.

Code

Design tokens at :root·css
/* Define tokens at :root */
:root {
  /* Color tokens — semantic, not literal */
  --color-bg: #ffffff;
  --color-fg: #1c2230;
  --color-accent: #5BA3D8;
  --color-danger: #d93b3b;
  --color-border: #e0e6ed;

  /* Spacing scale (geometric, base 0.25rem) */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --space-8: 2rem;

  /* Type scale */
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: 1.25rem;
  --text-xl: 1.5rem;

  /* Radii, shadows, etc. */
  --radius-md: 8px;
  --shadow-md: 0 2px 8px rgba(0,0,0,0.1);
}

/* Consume everywhere */
body { background: var(--color-bg); color: var(--color-fg); }
.btn { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); }
Theming + scoped redefinition·css
/* Theming via redefinition */

/* Light mode (default) */
:root {
  --bg: #ffffff;
  --fg: #1c2230;
  --accent: #1F5F8B;
}

/* Dark mode — same tokens, different values */
[data-theme="dark"] {
  --bg: #0b0e14;
  --fg: #e7ecf3;
  --accent: #5BA3D8;
}

/* Or via prefers-color-scheme */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0b0e14;
    --fg: #e7ecf3;
    --accent: #5BA3D8;
  }
}

/* Scoping: a section overrides for its subtree */
.alert-danger {
  --accent: var(--color-danger);  /* danger color for this region */
  --bg: #fef2f2;
}
.alert-danger .btn {
  background: var(--accent);      /* picks up the redefined value */
}
@property — typed custom properties·css
/* @property — typed, defaulted, animatable */

@property --gradient-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.rotating-border {
  background: linear-gradient(var(--gradient-angle), red, blue);
  animation: rotate 4s linear infinite;
}

@keyframes rotate {
  to { --gradient-angle: 360deg; }
}

/* Without @property, this animation wouldn't work — the browser
   doesn't know how to interpolate an untyped custom property. With
   @property declaring it as <angle>, the browser can smoothly tween
   from 0deg to 360deg. */

External links

Exercise

Build a small theme system: define 5-10 color tokens, a spacing scale, and a typography scale at :root. Add a dark theme via [data-theme="dark"] override. Add a button that toggles the theme via JavaScript. Add an alert region that redefines --accent locally without affecting siblings. Verify in DevTools that the theme switch updates every component automatically.
Hint
Use the CSS variables in concrete styles (.btn { background: var(--color-accent); padding: var(--space-2) var(--space-4); }). The toggle just does document.documentElement.dataset.theme = isDark ? 'dark' : 'light'. Inheritance + scoping does the rest.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.