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
--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
@mediaconditions) 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-schemeand a manualdata-themeoverride. - Override one token on a single card and confirm only that subtree re-themes.
- Type one token with
@propertyand animate it.