Tokens

51 level 1 tokens that a skin redefines, and 73 structural ones it does not. Every specimen below renders with var(--token), so the toggle in the top bar re-renders this page rather than describing it.

Level 1 — what a skin defines

These are the tokens in the skin contract. Each row shows the live value under the skin and mode you are reading in, then the two literals each shipped skin declares — the halves of its light-dark(), which is what you would copy as a starting point for a new one.

Typography

Four families. Which is used where is a component decision.

Typography tokens
LiveTokenPurposedefault light / darksepia light / dark

Sphinx of black quartz, judge my vow — 0123456789

--font-primaryheadingssystem-ui, sans-serif / system-ui, sans-serifGeorgia, "Iowan Old Style", serif / Georgia, "Iowan Old Style", serif

Sphinx of black quartz, judge my vow — 0123456789

--font-secondarybody copy and UIsystem-ui, sans-serif / system-ui, sans-serifGeorgia, "Iowan Old Style", serif / Georgia, "Iowan Old Style", serif

Sphinx of black quartz, judge my vow — 0123456789

--font-tertiarydecorative, accents, pull quotesGeorgia, serif / Georgia, serifsystem-ui, sans-serif / system-ui, sans-serif

Sphinx of black quartz, judge my vow — 0123456789

--font-monocode, tabular figures, keyboard keysui-monospace, SFMono-Regular, Menlo, monospace / ui-monospace, SFMono-Regular, Menlo, monospaceui-monospace, SFMono-Regular, Menlo, monospace / ui-monospace, SFMono-Regular, Menlo, monospace

Surfaces and content

Surfaces and content tokens
LiveTokenPurposedefault light / darksepia light / dark
--color-surfacepage backgroundoklch(99% 0.002 260) / oklch(17% 0.012 260)oklch(96% 0.022 85) / oklch(19% 0.018 60)
--color-surface-raisedcards, popovers, anything above the pageoklch(100% 0 0) / oklch(21% 0.014 260)oklch(98% 0.016 85) / oklch(23% 0.02 60)
--color-surface-sunkeninputs, wells, inset regionsoklch(96.5% 0.004 260) / oklch(13.5% 0.01 260)oklch(92.5% 0.03 85) / oklch(15.5% 0.015 60)
--color-surface-inversehigh-contrast blocksoklch(22% 0.012 260) / oklch(96% 0.003 260)oklch(28% 0.035 60) / oklch(94% 0.025 85)
--color-on-surfaceprimary textoklch(22% 0.012 260) / oklch(95% 0.004 260)oklch(29% 0.035 55) / oklch(92% 0.025 85)
--color-on-surface-mutedsecondary textoklch(48% 0.012 260) / oklch(72% 0.012 260)oklch(48% 0.035 60) / oklch(73% 0.03 80)
--color-on-surface-subtleplaceholders, disabled textoklch(53% 0.012 260) / oklch(61% 0.012 260)oklch(50% 0.032 65) / oklch(62% 0.028 80)
--color-on-surface-inversetext on --color-surface-inverseoklch(98% 0.002 260) / oklch(18% 0.012 260)oklch(96% 0.022 85) / oklch(19% 0.018 60)

Borders

Borders tokens
LiveTokenPurposedefault light / darksepia light / dark
--color-borderdefaultoklch(90% 0.006 260) / oklch(30% 0.014 260)oklch(85% 0.035 80) / oklch(32% 0.025 60)
--color-border-subtledividers, low-emphasis separationoklch(94.5% 0.004 260) / oklch(25% 0.012 260)oklch(90% 0.028 82) / oklch(26% 0.02 60)
--color-border-strongemphasis, focus-adjacentoklch(62% 0.012 260) / oklch(53% 0.018 260)oklch(57% 0.05 75) / oklch(55% 0.035 70)

Brand roles

Each with a hover, an active and a foreground.

Brand roles tokens
LiveTokenPurposedefault light / darksepia light / dark
--color-primaryoklch(53% 0.19 258) / oklch(70% 0.16 258)oklch(48% 0.11 40) / oklch(76% 0.1 45)
--color-primary-hoveroklch(49% 0.19 258) / oklch(76% 0.15 258)oklch(42% 0.11 40) / oklch(82% 0.09 45)
--color-primary-activeoklch(43% 0.175 258) / oklch(81% 0.13 258)oklch(36% 0.1 40) / oklch(87% 0.075 45)
--color-on-primaryoklch(99% 0 0) / oklch(17% 0.03 258)oklch(97% 0.02 85) / oklch(19% 0.03 45)
--color-secondaryoklch(52% 0.14 300) / oklch(70% 0.13 300)oklch(45% 0.07 150) / oklch(74% 0.08 150)
--color-secondary-hoveroklch(46% 0.14 300) / oklch(76% 0.12 300)oklch(39% 0.07 150) / oklch(80% 0.07 150)
--color-secondary-activeoklch(40% 0.13 300) / oklch(81% 0.11 300)oklch(34% 0.065 150) / oklch(85% 0.06 150)
--color-on-secondaryoklch(99% 0 0) / oklch(17% 0.03 300)oklch(97% 0.02 85) / oklch(19% 0.03 150)
--color-accentoklch(72% 0.17 65) / oklch(78% 0.15 65)oklch(52% 0.14 30) / oklch(76% 0.13 35)
--color-accent-hoveroklch(66% 0.17 65) / oklch(83% 0.14 65)oklch(46% 0.13 30) / oklch(82% 0.11 35)
--color-accent-activeoklch(59% 0.16 65) / oklch(88% 0.12 65)oklch(41% 0.12 30) / oklch(87% 0.09 35)
--color-on-accentoklch(16% 0.025 65) / oklch(18% 0.03 65)oklch(97% 0.02 85) / oklch(19% 0.035 35)

Status roles

A solid colour, a foreground for it, and a tinted surface.

Status roles tokens
LiveTokenPurposedefault light / darksepia light / dark
--color-infooklch(52% 0.14 240) / oklch(72% 0.12 240)oklch(50% 0.09 235) / oklch(75% 0.09 240)
--color-on-infooklch(99% 0 0) / oklch(17% 0.03 240)oklch(97% 0.02 85) / oklch(19% 0.03 240)
--color-info-surfaceoklch(96% 0.025 240) / oklch(26% 0.05 240)oklch(92% 0.035 235) / oklch(28% 0.045 240)
--color-successoklch(51% 0.13 150) / oklch(72% 0.13 150)oklch(50% 0.1 145) / oklch(75% 0.11 148)
--color-on-successoklch(99% 0 0) / oklch(16% 0.03 150)oklch(97% 0.02 85) / oklch(18% 0.03 148)
--color-success-surfaceoklch(96% 0.03 150) / oklch(25% 0.05 150)oklch(92% 0.04 145) / oklch(27% 0.045 148)
--color-warningoklch(56% 0.13 70) / oklch(80% 0.14 75)oklch(54% 0.12 68) / oklch(81% 0.13 72)
--color-on-warningoklch(99% 0.01 75) / oklch(18% 0.03 75)oklch(99% 0.012 70) / oklch(20% 0.035 72)
--color-warning-surfaceoklch(93.5% 0.055 75) / oklch(28% 0.06 75)oklch(91% 0.06 70) / oklch(30% 0.05 72)
--color-dangeroklch(56% 0.2 25) / oklch(70% 0.17 25)oklch(50% 0.16 28) / oklch(73% 0.15 28)
--color-on-dangeroklch(99% 0 0) / oklch(16% 0.03 25)oklch(97% 0.02 85) / oklch(19% 0.03 28)
--color-danger-surfaceoklch(96% 0.03 25) / oklch(27% 0.07 25)oklch(92% 0.045 28) / oklch(28% 0.06 28)

Utility

Utility tokens
LiveTokenPurposedefault light / darksepia light / dark
--color-focusfocus ringoklch(53% 0.19 258) / oklch(76% 0.15 258)oklch(48% 0.11 40) / oklch(76% 0.13 35)
--color-shadowthe colour elevation is built fromoklch(30% 0.02 260) / oklch(0% 0 0)oklch(35% 0.05 60) / oklch(4% 0.01 60)
--color-overlaydialog and drawer backdropsoklch(22% 0.012 260 / 0.5) / oklch(8% 0.01 260 / 0.65)oklch(29% 0.035 55 / 0.45) / oklch(10% 0.015 60 / 0.66)
--color-selection::selection backgroundoklch(88% 0.08 258) / oklch(40% 0.12 258)oklch(85% 0.08 55) / oklch(38% 0.09 45)

Shape

Shape tokens
LiveTokenPurposedefault light / darksepia light / dark
--radius-none0 / 00 / 0
--radius-sm0.25rem / 0.25rem0.125rem / 0.125rem
--radius-md0.5rem / 0.5rem0.1875rem / 0.1875rem
--radius-lg0.75rem / 0.75rem0.25rem / 0.25rem
--radius-xl1rem / 1rem0.375rem / 0.375rem
--radius-full9999px / 9999px9999px / 9999px
--border-width1px / 1px1px / 1px
--border-width-strong2px / 2px2px / 2px

All 51 tokens are declared by all 2 skins, in both modes. That is not a coincidence you have to trust: this page is generated by reading the contract and each skin file, and a token missing from a skin would show as in that column.

Structural — what stays the same across skins

The rhythm, the type scale, motion and elevation geometry are not thematic. A sepia skin is not a reason to change what --space-4 means. The one exception is --space-scale, which a skin may set to change density.

Spacing scale

Structural, not thematic: the rhythm stays the same across themes. What a theme (or a section, or a single component) may change is `--space-scale`, which multiplies the whole scale at once. That multiplier is what "density" means here — compact and comfortable are not separate features, they are `--space-scale: 0.8` and `--space-scale: 1`. Because the multiplier is inherited, you can make one region denser without touching anything else: .sidebar { --space-scale: 0.85; }

Spacing scale tokens
LiveTokenValuePurpose
--space-unit0.25remThe atom every step is built from. Change this and the whole UI rescales.

Unitless multiplier, currently ×1

--space-scale1Density multiplier. Inherited, so it can be scoped to any subtree.
--space-00
--space-px1px
--space-1calc(var(--space-unit) * 1 * var(--space-scale))
--space-2calc(var(--space-unit) * 2 * var(--space-scale))
--space-3calc(var(--space-unit) * 3 * var(--space-scale))
--space-4calc(var(--space-unit) * 4 * var(--space-scale))
--space-5calc(var(--space-unit) * 5 * var(--space-scale))
--space-6calc(var(--space-unit) * 6 * var(--space-scale))
--space-8calc(var(--space-unit) * 8 * var(--space-scale))
--space-10calc(var(--space-unit) * 10 * var(--space-scale))
--space-12calc(var(--space-unit) * 12 * var(--space-scale))
--space-16calc(var(--space-unit) * 16 * var(--space-scale))
--space-20calc(var(--space-unit) * 20 * var(--space-scale))
--space-24calc(var(--space-unit) * 24 * var(--space-scale))
--space-32calc(var(--space-unit) * 32 * var(--space-scale))
--scroll-padding6.25remHow far above an anchor target the viewport stops. Set this to the height of a sticky header so in-page links do not land underneath it.

Source: src/css/tokens/spacing.css

Typographic scale

Structural: the sizes and rhythm live here and do not change per theme. The font *families* do change per theme, and are defined in themes/ instead — see themes/_contract.css for the four families every theme must provide. Sizes are fluid. Each step interpolates between a minimum and a maximum across the viewport using `vi` (the inline-axis viewport unit), so they keep working in vertical writing modes. There are no breakpoints to maintain. The clamp() minimum is always in rem so browser zoom and user font-size settings keep working — never lock a font size to a viewport unit alone.

Typographic scale tokens
LiveTokenValuePurpose

Grumpy wizards make toxic brew

--font-size-xsclamp(0.75rem, 0.73rem + 0.1vi, 0.8125rem)

Grumpy wizards make toxic brew

--font-size-smclamp(0.875rem, 0.85rem + 0.125vi, 0.9375rem)

Grumpy wizards make toxic brew

--font-size-base1remBody text is the one step that is *not* fluid, and it is deliberate. It used to be `clamp(1rem, 0.96rem + 0.2vi, 1.125rem)`, which on a 1440px screen reads 18px. Reviewed on 12 August 2026 on a page of running prose — the privacy notice — and 17.7px was called too big: a paragraph does not get easier to read by growing with the window, it gets easier by keeping a measure, and `--measure` already does that. The headings stay fluid, because a *title* does scale with the room it has. 1rem, not 16px: whatever the reader set in the browser is the size.

Grumpy wizards make toxic brew

--font-size-lgclamp(1.125rem, 1.06rem + 0.32vi, 1.3125rem)

Grumpy wizards make toxic brew

--font-size-xlclamp(1.375rem, 1.26rem + 0.57vi, 1.75rem)

Grumpy wizards make toxic brew

--font-size-2xlclamp(1.75rem, 1.55rem + 1vi, 2.375rem)

Grumpy wizards make toxic brew

--font-size-3xlclamp(2.25rem, 1.9rem + 1.75vi, 3.25rem)

Grumpy wizards make toxic brew

--font-size-4xlclamp(2.875rem, 2.3rem + 2.9vi, 4.5rem)

Two lines of text, so the leading between them is the thing you are looking at rather than something you have to imagine.

--line-height-none1

Two lines of text, so the leading between them is the thing you are looking at rather than something you have to imagine.

--line-height-tight1.15

Two lines of text, so the leading between them is the thing you are looking at rather than something you have to imagine.

--line-height-snug1.35

Two lines of text, so the leading between them is the thing you are looking at rather than something you have to imagine.

--line-height-normal1.6

Two lines of text, so the leading between them is the thing you are looking at rather than something you have to imagine.

--line-height-relaxed1.8

Tracking changes the colour of a paragraph

--letter-spacing-tighter-0.03em

Tracking changes the colour of a paragraph

--letter-spacing-tight-0.015em

Tracking changes the colour of a paragraph

--letter-spacing-normal0em

Tracking changes the colour of a paragraph

--letter-spacing-wide0.025em

Tracking changes the colour of a paragraph

--letter-spacing-wider0.08em

Grumpy wizards make toxic brew

--font-weight-normal400

Grumpy wizards make toxic brew

--font-weight-medium500

Grumpy wizards make toxic brew

--font-weight-semibold600

Grumpy wizards make toxic brew

--font-weight-bold700
--measure65chComfortable measure for running text. Used by .prose.
--measure-narrow45ch
--measure-wide80ch

Source: src/css/tokens/typography.css

Motion

Structural. Durations collapse to near-zero under `prefers-reduced-motion`, which is handled here once rather than in every component: as long as a component animates using these tokens, it respects the preference for free. Near-zero rather than zero, because a 0s transition never fires `transitionend`, and some components rely on that event to clean up.

Motion tokens
LiveTokenValuePurpose
--duration-instant70ms
--duration-fast130ms
--duration-normal220ms
--duration-slow400ms
--duration-slower700ms
--ease-linearlinear
--ease-incubic-bezier(0.4, 0, 1, 1)
--ease-outcubic-bezier(0, 0, 0.2, 1)
--ease-in-outcubic-bezier(0.4, 0, 0.2, 1)
--ease-springcubic-bezier(0.34, 1.56, 0.64, 1)Overshoots slightly. For things entering the screen, never for exits.

Source: src/css/tokens/motion.css

Elevation

The *geometry* of each step lives here and is structural. The *colour* comes from `--color-shadow`, which every theme defines — so a dark theme gets shadows that read correctly without needing a different elevation mechanism. This deliberately keeps one mechanism across all themes. Swapping shadows for glowing borders in dark mode is a real technique, but it makes every component's elevation conditional on the theme, and that cost is not worth paying for a library that must support an unbounded number of themes. Opacity is baked into each step rather than exposed, because a shadow's alpha is not something anyone tunes independently of its blur.

Elevation tokens
LiveTokenValuePurpose
--elevation-0none
--elevation-10 1px 2px rgb(from var(--color-shadow) r g b / 0.06), 0 1px 3px rgb(from var(--color-shadow) r g b / 0.1)
--elevation-20 2px 4px rgb(from var(--color-shadow) r g b / 0.06), 0 4px 8px rgb(from var(--color-shadow) r g b / 0.1)
--elevation-30 4px 8px rgb(from var(--color-shadow) r g b / 0.06), 0 8px 16px rgb(from var(--color-shadow) r g b / 0.11)
--elevation-40 8px 16px rgb(from var(--color-shadow) r g b / 0.07), 0 16px 32px rgb(from var(--color-shadow) r g b / 0.12)
--elevation-50 16px 32px rgb(from var(--color-shadow) r g b / 0.08), 0 32px 64px rgb(from var(--color-shadow) r g b / 0.14)
--elevation-overlayvar(--elevation-4)For elements sitting in the top layer: dialogs, popovers, menus.

Source: src/css/tokens/elevation.css

Shape roles

The radius *scale* (--radius-sm … --radius-full) is thematic and lives in themes/. These three are the semantic roles built on top of it: what a selector, a field and a box are shaped like. They are derived, so restating them in every skin would be three lines of copied boilerplate that never vary — a skin should only have to state what it actually changes. A skin may still override any of them (a brutalist skin with square boxes and pill-shaped fields is a legitimate thing to want). `:where()` keeps the specificity at zero so overriding takes nothing more than restating it. WHY THE SELECTOR IS `:where(:root, [data-skin])` AND NOT JUST `:root` Custom properties are substituted at computed-value time on the element that declares them, and the *result* is what descendants inherit. If these were declared only on `:root`, `var(--radius-md)` would resolve once against the root's scale, and a scoped skin — `<div data-skin="sepia">` inside a default page — would inherit the root's radii instead of sepia's. Re-declaring on every skinned element makes each one resolve against its own scale.

Shape roles tokens
LiveTokenValuePurpose
--radius-selectorvar(--radius-sm)checkboxes, radios, switches
--radius-fieldvar(--radius-md)inputs, buttons — anything you type into or press
--radius-boxvar(--radius-lg)cards, dialogs, panels
--focus-ring-width3pxThe focus ring, as one value for `outline` plus its offset. It exists because the ring frequently has to be drawn on an element that is not the focused one: a visually hidden radio focuses, and the ring belongs on its <label>. Retyping the outline by hand in every such control is how rings drift apart. Written as: input:focus-visible + label { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); } Derived like the shape roles above, and re-resolved per themed element for the same reason. WHY IT IS 3px AND NOT `--border-width-strong` It was the strong border width, which on most skins is 2px, and 2px at the same offset was reported as "the visible focus ring doesn't look very visible". WCAG 2.2's Focus Appearance (2.4.11) asks for an area at least as large as a 2px perimeter *and* a 3:1 change against what was there before; 2px hits the floor exactly and leaves nothing for a ring that lands on a busy control. Adrian Roselli's point about two-colour indicators is the other half, and the offset is what supplies it: the gap paints the element's own background between the control and the ring, so one of the two edges always contrasts whatever is behind it. A skin can lower it, but a skin that does had better have measured.
--focus-ringvar(--focus-ring-width) solid var(--color-focus)
--focus-ring-offset2px

Source: src/css/tokens/shape.css

Container widths, and the floor under anything you tap

`--measure` and friends in typography.css answer "how wide should a line of text be", in `ch`, and they are about reading comfort. These answer "how wide is a content column", in `rem`, and they are about page structure. Both are design-system decisions rather than per-page ones, which is why they are tokens — unlike a gap, which is genuinely local and stays inline. They are deliberately *not* a container component. Using one is `max-inline-size: var(--container); margin-inline: auto;` — two declarations, where a class would have to expose a custom property to be reusable and you would end up writing both anyway. ON BREAKPOINTS There is no `--breakpoint-*` token, because CSS cannot use `var()` inside a media query — `@media (min-width: var(--bp-md))` does not work, and shipping a token nobody can use in the one place they want it is worse than shipping none. Prefer container queries, which need no breakpoint at all. Where a viewport media query is unavoidable, the convention is 40rem / 60rem / 80rem, written literally. TARGET SIZE — THE NUMBERS, BECAUSE THE ADVICE YOU HAVE READ IS THREE DIFFERENT NUMBERS They are not in conflict; they are different requirements, and a library has to say which one it meets: 24×24 WCAG 2.2 SC 2.5.8 Target Size (Minimum) — **level AA**, with a spacing exception: a 24px circle centred on the target may not overlap another target's circle. 44×44 WCAG 2.2 SC 2.5.5 Target Size (Enhanced) — level **AAA**. Also Apple's HIG figure, in points. 48×48 Material, in dp. So 44 is the touch convention and the AAA bar, not the AA requirement, and a 40px control is conformant at AA. It is still the wrong default for a phone, which is what these two tokens are for. WHY THE FLOOR MOVES WITH THE POINTER A 44px row of controls is right under a thumb and wasteful under a cursor — that is the whole reason design systems sit at 36–40 and then get this review. The platform already models the difference, so nothing here has to guess: the fine-pointer values below are exactly what this library shipped before, and the coarse ones are 44. `any-pointer`, not `pointer`. A laptop with a touchscreen reports `pointer: fine` — its *primary* input is the trackpad — while `any-pointer: coarse` matches, and erring towards the larger target is the safe direction of that error. Measured in Chromium, Firefox and WebkKit: all three match `any-pointer: coarse` under touch emulation. WHAT THE `-sm` VARIANTS COST, STATED RATHER THAN IMPLIED They do not read these tokens. `.btn-sm` is 32px, `.nav-sm` and `.segmented-sm` are 28px, and that is a deliberate density decision for pointer-first interfaces — a toolbar, a data table's row actions, a settings strip on a desktop. All of them clear the AA floor of 24; none of them reaches 44. **If the control is a touch target, do not use the `-sm` variant**, or raise the component's own `--*-min-size` back to `var(--target-size)` yourself. Nothing can detect which case you are in.

Container widths, and the floor under anything you tap tokens
LiveTokenValuePurpose
--container-narrow48rem
--container68rem
--container-wide80rem
--container-full100%
--target-size-min1.5remThe AA floor, and the only one of the three that never moves: 24px is a conformance requirement, not a preference, so a pointer cannot make it smaller. It is what the little controls clamp to — a tag's remove button, a star in a rating — where the comfortable floor would change the shape of the thing they sit in.
--target-size2.5remA control you press: a button, a field, a select.
--target-size-compact2.25remOne row of a list of them: a menu item, a nav link, a segment, a chip. Smaller under a cursor because it is one of many in a column.

Source: src/css/tokens/sizing.css

Prefer a container query to a breakpoint. There is deliberately no --breakpoint-* token, because var() does not work inside a media query. Where an ancestor can be a container, you do not need one at all — ask how much room the component has rather than how wide the window is:
.main { container: main / inline-size } then @container main (min-inline-size: 60rem).
This page's theme generator switches to two columns that way. It survives minification — the built CSS reads @container main (inline-size>=60rem)— and it keeps working when the component is placed in a narrow column on a wide screen, which a viewport media query gets wrong. Where a viewport query is genuinely unavoidable, the convention is 40rem / 60rem / 80rem, written literally.