Skip to content

Tokens and surfaces

Tokens are the names components read. There is no second layer of aliases: a custom property whose value contains var() is substituted where it is declared, so an alias defined at :root resolves there and cannot be re-pointed in a subtree.

@theme {
--control-font-size: var(--text-sm); /* broken: resolved at :root */
--font-sans: var(
--font-family,
ui-sans-serif
); /* worse: the fallback hides the failure */
}

The second shape is the sharper one. A bare alias renders nothing and eventually gets noticed; an alias with a fallback renders a plausible system font and nobody reports it.

Group Tokens Notes
colour — surface tier --color-background, --color-foreground, --color-muted, --color-muted-foreground, --color-border, --color-highlight, --color-highlight-foreground flip per surface; --color-input and --color-ring follow --color-border
colour — brand tier --color-primary, --color-secondary, --color-brand, --color-destructive, each with a -foreground inherit across surfaces; override only on collision
shape --radius-base one radius, not an sm/md/lg scale
controls --control-height, --control-padding-x, --control-font-size read by buttons, inputs and selects alike, so one declaration sizes a whole form
rhythm --flow-gap spacing between stacked children
layout --container-max, --container-padding, --sheet-width, --sheet-height read by Container and Sheet
type --font-sans the theme variable is the token

Fifteen of these are colour. The entire non-colour contract is ten names, and a brand that only changes colour touches none of them.

--color-brand is the identity colour. --color-accent is retired — removed rather than aliased, so a stale override is inert and greppable instead of silently re-pointing the identity colour.

A surface is a set of colour roles swapped as a unit. A component reads --color-background and --color-foreground and never knows which surface it is on, so it renders correctly anywhere.

[data-surface="inverse"] {
--color-background: var(--gray-950);
--color-foreground: var(--gray-50);
--color-border: var(--gray-800);
--color-highlight: var(--gray-800);
}

Surfaces are named for the plane they describe — base, raised, inverse, brand — never for a role. Naming one after a role is what produced the accent collision: a token that was both a brand colour and the obvious name for a plane, so neither meaning could be relied on.

Surfaces nest. A base surface inside an inverse section is light again, and only the roles a surface restates change — the rest inherit from the enclosing surface.

Dark mode is not a mode. The root carries a surface, and prefers-color-scheme chooses which.

Applying a surface without touching markup. A surface is a rule, so add the selector to it:

[data-surface="brand"],
[data-component="content-teaser"] {
--color-background: var(--brand-500);
--color-foreground: white;
}

Decidable, not a matter of taste. A brand-owned role is restated on a surface only where that brand’s colour collides with that plane — so another brand could delete the override.

Ask: name a brand that deletes this line. primary passes — an orange that reads on both light and dark needs no restatement. highlight never could: a pale grey hover is invisible on dark for every brand there will be, so it is surface-owned.

Both tests must pass.

Is it an input? Something downstream reads it — a container sets it and descendants act on it, one value drives several properties, or the component decides how to apply it. A value only ever set on the element that uses it is a plain property.

Does it compete with Tailwind? Add a token only for something Tailwind does not already name. A display-xl / heading-lg scale sits in the same space as text-4xl, so every author has to decide which vocabulary to reach for. If a developer could be unsure which to use, do not add it.

And a brand has to have actually needed it.