Custom Properties and Design Tokens

Custom properties (--name) are CSS's own variables: declared once, inherited down the tree, resolved at use time. They are the mechanism behind theming, dark mode, and every design-token system — and they change how the cascade itself behaves, so they deserve their own lesson.

Declaration and resolution

Declare and use

/* Declare on :root: available to the whole document.
   Property names are case-sensitive; values can be anything CSS can parse. */
:root {
  --brand: #38bdf8;
  --space-3: 1rem;
  --shadow-card: 0 4px 12px rgb(0 0 0 / 0.35);
}

/* Use with var() - the property must still be a real, valid property */
.card {
  border-color: var(--brand);
  padding: var(--space-3);
  box-shadow: var(--shadow-card);
}

Resolution follows the tree — the nearest ancestor wins

Tree diagram showing a custom property defined on :root and overridden on a subtree
A custom property is inherited: each subtree resolves it to the nearest definition on its ancestor chain. Overriding --accent on one branch re-themes it, and only it.
/* Local override - every var(--brand) BELOW this element changes */
.card.highlight { --brand: #f59e0b; }
/* The card's border-color (and any descendant using --brand) is now
   amber; the rest of the page keeps the sky blue. */

Fallbacks and the invalid-value trap

/* var() takes an optional fallback for UNDEFINED variables */
color: var(--text, #e2e8f0);
color: var(--text, var(--text-alt, #e2e8f0));  /* fallback chains nest */

/* The trap: a missing variable makes the value "guaranteed-invalid",
   which for MOST properties means the declaration computes to inherit
   or initial - NOT the fallback, and NOT silently ignored. */
color: var(--typo-name, blue);   /* if --typo-name doesn't exist -> fallback works */
color: var(--typo-name);         /* if undefined -> color becomes inherited/initial */

Theming with media queries and data attributes

Dark mode in ten lines

Because variables inherit and re-resolve, a theme is just a different set of token values. Both the OS preference and an explicit toggle can drive it:

:root {
  --bg: #ffffff;
  --ink: #0f172a;
  --brand: oklch(0.62 0.19 260);
}

@media (prefers-color-scheme: dark) {
  :root { --bg: #0f172a; --ink: #e2e8f0; }
}

/* An explicit user toggle beats the OS default: a data attribute
   on <html> re-declares the tokens for the entire subtree */
[data-theme="dark"] {
  --bg: #0f172a;
  --ink: #e2e8f0;
}

body { background: var(--bg); color: var(--ink); }

Tokens: naming and layers

Mature token systems separate primitive values (raw colors, sizes) from semantic tokens (what they are for). Components read only the semantic layer, so re-skinning never touches a component:

:root {
  /* primitives: never used directly by components */
  --sky-500: #0ea5e9;
  --slate-900: #0f172a;

  /* semantic: the only layer components read */
  --color-action: var(--sky-500);
  --color-surface: var(--slate-900);
  --color-surface-ink: #e2e8f0;
}

/* A component speaks the semantic language only */
.card { background: var(--color-surface); color: var(--color-surface-ink); }
.btn-primary { background: var(--color-action); }

Typing tokens with @property

/* Custom properties have no type by default: they are strings, so they
   do not animate and cannot transition. @property gives them a type. */
@property --theme-angle {
  syntax: "<angle>";       /* now a real angle value */
  inherits: false;
  initial-value: 0deg;
}

.card {
  background: conic-gradient(from var(--theme-angle), #38bdf8, #f59e0b);
  transition: --theme-angle 600ms;   /* now this actually animates */
}
.card:hover { --theme-angle: 180deg; }

What custom properties cannot do

The limits are informative

  • They are not preprocessor variables: no logic, no loops, no inclusion — by design.
  • A custom property used in a selector-context mismatch (e.g. inside @media conditions) cannot change media queries; queries are not values.
  • Values are strings until used: var() in a URL or inside a selector never works.

Practice: token workout

The task

Open demo/foundations/06_custom_properties.html from the Lab Examples page, then build a token set for a real page.

The checklist

  • Define primitive color/size tokens and a semantic layer on top; components read only semantics.
  • Add a dark theme via prefers-color-scheme and a manual data-theme override.
  • Override one token on a single card and confirm only that subtree re-themes.
  • Type one token with @property and animate it.