Tokens
51 level 1 tokens that a skin redefines, and 101 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.
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
Sphinx of black quartz, judge my vow — 0123456789 | --font-primary | headings | system-ui, sans-serif / system-ui, sans-serif |
Sphinx of black quartz, judge my vow — 0123456789 | --font-secondary | body copy and UI | system-ui, sans-serif / system-ui, sans-serif |
Sphinx of black quartz, judge my vow — 0123456789 | --font-tertiary | decorative, accents, pull quotes | Georgia, serif / Georgia, serif |
Sphinx of black quartz, judge my vow — 0123456789 | --font-mono | code, tabular figures, keyboard keys | ui-monospace, SFMono-Regular, Menlo, monospace / ui-monospace, SFMono-Regular, Menlo, monospace |
Surfaces and content
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
| --color-surface | page background | oklch(98.5% 0.002 260) / oklch(16% 0.003 260) | |
| --color-surface-raised | cards, popovers, anything above the page | oklch(100% 0 260) / oklch(24% 0.004 260) | |
| --color-surface-sunken | inputs, wells, inset regions | oklch(94.5% 0.005 260) / oklch(0% 0 260) | |
| --color-surface-inverse | high-contrast blocks | oklch(22% 0.012 260) / oklch(96% 0.003 260) | |
| --color-on-surface | primary text | oklch(15% 0.005 260) / oklch(100% 0 260) | |
| --color-on-surface-muted | secondary text | oklch(33% 0.007 260) / oklch(87% 0.005 260) | |
| --color-on-surface-subtle | placeholders, disabled text | oklch(38% 0.007 260) / oklch(82% 0.006 260) | |
| --color-on-surface-inverse | text on --color-surface-inverse | oklch(98% 0.002 260) / oklch(18% 0.012 260) |
Borders
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
| --color-border | default | oklch(58% 0.009 260) / oklch(62% 0.008 260) | |
| --color-border-subtle | dividers, low-emphasis separation | oklch(72% 0.008 260) / oklch(46% 0.008 260) | |
| --color-border-strong | emphasis, focus-adjacent | oklch(38% 0.008 260) / oklch(82% 0.006 260) |
Brand roles
Each with a hover, an active and a foreground.
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
| --color-primary | — | oklch(42% 0.19 260.6) / oklch(80% 0.13 251.5) | |
| --color-primary-hover | — | oklch(37% 0.17 260.6) / oklch(85% 0.1 251.2) | |
| --color-primary-active | — | oklch(32% 0.15 260.6) / oklch(90% 0.07 251.3) | |
| --color-on-primary | — | oklch(99% 0 0) / oklch(17% 0.03 258) | |
| --color-secondary | — | oklch(42% 0.2 279.3) / oklch(78% 0.13 274.7) | |
| --color-secondary-hover | — | oklch(37% 0.19 279.2) / oklch(83% 0.1 274.4) | |
| --color-secondary-active | — | oklch(32% 0.17 279.3) / oklch(88% 0.08 274.7) | |
| --color-on-secondary | — | oklch(99% 0 0) / oklch(17% 0.03 300) | |
| --color-accent | — | oklch(45% 0.13 46) / oklch(84% 0.12 57.1) | |
| --color-accent-hover | — | oklch(40% 0.12 45.9) / oklch(88% 0.1 59.7) | |
| --color-accent-active | — | oklch(35% 0.1 46) / oklch(92% 0.08 58.8) | |
| --color-on-accent | — | oklch(16% 0.025 65) / oklch(18% 0.03 65) |
Status roles
A solid colour, a foreground for it, and a tinted surface.
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
| --color-info | — | oklch(44% 0.1 234.2) / oklch(86% 0.11 221.8) | |
| --color-on-info | — | oklch(99% 0 0) / oklch(17% 0.03 240) | |
| --color-info-surface | — | oklch(96% 0.025 240) / oklch(26% 0.05 240) | |
| --color-success | — | oklch(43% 0.13 147.4) / oklch(84% 0.18 147) | |
| --color-on-success | — | oklch(99% 0 0) / oklch(16% 0.03 150) | |
| --color-success-surface | — | oklch(96% 0.03 150) / oklch(25% 0.05 150) | |
| --color-warning | — | oklch(45% 0.1 73.2) / oklch(92% 0.16 94.9) | |
| --color-on-warning | — | oklch(99% 0.01 75) / oklch(18% 0.03 75) | |
| --color-warning-surface | — | oklch(93.5% 0.055 75) / oklch(28% 0.06 75) | |
| --color-danger | — | oklch(45% 0.19 24.6) / oklch(76% 0.18 25.1) | |
| --color-on-danger | — | oklch(99% 0 0) / oklch(16% 0.03 25) | |
| --color-danger-surface | — | oklch(96% 0.03 25) / oklch(27% 0.07 25) |
Utility
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
| --color-focus | focus ring | oklch(42% 0.19 260.6) / oklch(80% 0.13 251.5) | |
| --color-shadow | the colour elevation is built from | oklch(30% 0.02 260) / oklch(0% 0 0) | |
| --color-overlay | dialog and drawer backdrops | oklch(22% 0.012 260 / 0.5) / oklch(8% 0.01 260 / 0.65) | |
| --color-selection | ::selection background | oklch(88% 0.08 258) / oklch(40% 0.12 258) |
Shape
| Live | Token | Purpose | default light / dark |
|---|---|---|---|
| --radius-none | — | 0 / 0 | |
| --radius-sm | — | 0.25rem / 0.25rem | |
| --radius-md | — | 0.5rem / 0.5rem | |
| --radius-lg | — | 0.75rem / 0.75rem | |
| --radius-xl | — | 1rem / 1rem | |
| --radius-full | — | 9999px / 9999px | |
| --border-width | — | 2px / 2px | |
| --border-width-strong | — | 2px / 2px |
All 51 tokens are declared by the skin that ships, 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 new 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.
DENSITY IS AN ATTRIBUTE, NOT A LOOSE NUMBER
This file used to say «because the multiplier is inherited, you can make one
region denser without touching anything else: .sidebar { --space-scale:
0.85 }». That never worked. The steps below are declared on :root, so
their var(--space-scale) is substituted there and every descendant
inherits a resolved length; a --space-scale written further down has
nothing left to multiply. Measured 17 August 2026: --space-4 came out 16px
at scale 1, at 0.5 and at 2 alike. It is the same trap _contract.css
records for scoped skins, and the same answer — the tokens have to be
re-declared where the override is made.
<aside data-density="compact">…</aside>
[data-density] re-declares the whole scale, so the multiplier resolves on
that element and the region below it is denser. A closed set rather than a
free number, for the reason Cloudscape gives: density is a system-level
decision, and a scale anyone can write anywhere compounds — 0,85 inside 0,85
is 0,72, and nobody chose that.
| Live | Token | Value | Purpose |
|---|---|---|---|
| --space-unit | 0.25rem | The atom every step is built from. Change this and the whole UI rescales. | |
Unitless multiplier, currently ×1 | --space-scale | 1 | Density multiplier. Inherited, so it can be scoped to any subtree. |
| --space-0 | 0 | — | |
| --space-px | 1px | — | |
| --space-1 | max(2px, calc(var(--space-unit) * 1 * var(--space-scale))) | — | |
| --space-2 | calc(var(--space-unit) * 2 * var(--space-scale)) | — | |
| --space-3 | calc(var(--space-unit) * 3 * var(--space-scale)) | — | |
| --space-4 | calc(var(--space-unit) * 4 * var(--space-scale)) | — | |
| --space-5 | calc(var(--space-unit) * 5 * var(--space-scale)) | — | |
| --space-6 | calc(var(--space-unit) * 6 * var(--space-scale)) | — | |
| --space-8 | calc(var(--space-unit) * 8 * var(--space-scale)) | — | |
| --space-10 | calc(var(--space-unit) * 10 * var(--space-scale)) | — | |
| --space-12 | calc(var(--space-unit) * 12 * var(--space-scale)) | — | |
| --space-16 | calc(var(--space-unit) * 16 * var(--space-scale)) | — | |
| --space-20 | calc(var(--space-unit) * 20 * var(--space-scale)) | — | |
| --space-24 | calc(var(--space-unit) * 24 * var(--space-scale)) | — | |
| --space-32 | calc(var(--space-unit) * 32 * var(--space-scale)) | — | |
| — | --rhythm-page | calc(var(--space-unit) * 25 * var(--space-scale)) | — |
| — | --rhythm-section | calc(var(--space-unit) * 12.5 * var(--space-scale)) | — |
| — | --rhythm-block | calc(var(--space-unit) * 6 * var(--space-scale)) | — |
| — | --rhythm-line | calc(var(--space-unit) * 4 * var(--space-scale)) | — |
| — | --scroll-padding | 6.25rem | How 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.
| Live | Token | Value | Purpose |
|---|---|---|---|
Grumpy wizards make toxic brew | --font-size-xs | clamp(0.75rem, 0.73rem + 0.1vi, 0.8125rem) | — |
Grumpy wizards make toxic brew | --font-size-sm | clamp(0.875rem, 0.85rem + 0.125vi, 0.9375rem) | — |
Grumpy wizards make toxic brew | --font-size-base | 1rem | — |
Grumpy wizards make toxic brew | --font-size-lg | clamp(1.125rem, 1.06rem + 0.32vi, 1.3125rem) | — |
Grumpy wizards make toxic brew | --font-size-xl | clamp(1.375rem, 1.26rem + 0.57vi, 1.75rem) | — |
Grumpy wizards make toxic brew | --font-size-2xl | clamp(1.75rem, 1.55rem + 1vi, 2.375rem) | — |
Grumpy wizards make toxic brew | --font-size-3xl | clamp(2.25rem, 1.9rem + 1.75vi, 3.25rem) | — |
Grumpy wizards make toxic brew | --font-size-4xl | clamp(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-none | 1 | — |
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-tight | 1.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-snug | 1.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-normal | 1.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-relaxed | 1.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-normal | 0em | — |
Tracking changes the colour of a paragraph | --letter-spacing-wide | 0.025em | — |
Tracking changes the colour of a paragraph | --letter-spacing-wider | 0.08em | — |
Tracking changes the colour of a paragraph | --letter-spacing-body | var(--letter-spacing-normal) | — |
Tracking changes the colour of a paragraph | --letter-spacing-title | var(--letter-spacing-tight) | — |
Tracking changes the colour of a paragraph | --letter-spacing-section | var(--letter-spacing-tight) | — |
Tracking changes the colour of a paragraph | --letter-spacing-subsection | var(--letter-spacing-tight) | — |
Grumpy wizards make toxic brew | --font-weight-normal | 400 | — |
Grumpy wizards make toxic brew | --font-weight-medium | 500 | — |
Grumpy wizards make toxic brew | --font-weight-semibold | 600 | — |
Grumpy wizards make toxic brew | --font-weight-bold | 700 | — |
| --measure | 65ch | — | |
| --measure-narrow | 45ch | — | |
| --measure-wide | 80ch | — |
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.
SPRINGS, AND WHY THEY ARE NOT BÉZIERS
Apple's interfaces settle rather than ease. A bézier is a shape somebody
drew; a spring is what the shape becomes once you have said how quickly the
thing should respond and how much friction it has. The difference is not
subtle at the end of the movement, which is where a bézier arrives dead and a
spring arrives alive, and it is the reason a cubic-bezier(0.34, 1.56, …) —
which is what this file used to ship — reads as a cartoon bounce rather than
as weight.
linear() is what makes the real thing expressible. Each curve below is
SwiftUI's own spring, sampled:
omega = 2π / response zeta = dampingFraction
x(t) = 1 − e^(−ζωt)·(cos(ω_d t) + (ζω/ω_d)·sin(ω_d t))
integrated at 27 stops and cut where the curve comes within a thousandth of
rest and stays there — which is also where the matching --duration-* comes
from. The names are SwiftUI's, so .smooth, .snappy and .bouncy mean
here what they mean there.
WHICH ONE TO REACH FOR
--ease-quick a press, a hover, a checkbox. 300ms response, barely
overshoots. The one most interactions want.
--ease-snappy a sheet, a popover, a drawer arriving. The default.
--ease-smooth anything leaving. It is the only one that does not
overshoot, and an exit that overshoots pushes the element
back on screen for a frame before it goes.
--ease-bouncy a toggle, a like, something that should feel pleased with
itself. Deliberately the loudest thing here; used sparingly,
per the HIG's "don't add motion for the sake of motion".
tests/e2e/motion.spec.ts pins the three things about these that are
invisible on screen: that every curve starts at 0 and ends at exactly 1,
that the calm one does not overshoot and the bouncy one does, and that the
reduced-motion preference reaches the easings and not only the durations.
| Live | Token | Value | Purpose |
|---|---|---|---|
| --duration-instant | 70ms | — | |
| --duration-fast | 130ms | — | |
| --duration-normal | 220ms | — | |
| --duration-slow | 400ms | — | |
| --duration-slower | 700ms | — | |
| --duration-quick | 400ms | — | |
| --duration-snappy | 700ms | — | |
| --duration-smooth | 736ms | — | |
| --duration-bouncy | 820ms | — | |
| --ease-linear | linear | — | |
| --ease-in | cubic-bezier(0.4, 0, 1, 1) | — | |
| --ease-out | cubic-bezier(0, 0, 0.2, 1) | — | |
| --ease-in-out | cubic-bezier(0.4, 0, 0.2, 1) | — | |
| --ease-quick | linear( 0, 0.0428, 0.1416, 0.2639, 0.3899, 0.5081, 0.6127, 0.7015, 0.7745, 0.833, 0.8788, 0.9139, 0.9403, 0.9598, 0.9738, 0.9837, 0.9905, 0.9951, 0.998, 0.9998, 1.0008, 1.0013, 1.0015, 1.0015, 1.0014, 1.0012, 1 ) | response 0.3s, damping 0.9 — peak 1.002 | |
| --ease-snappy | linear( 0, 0.047, 0.1552, 0.2887, 0.425, 0.5511, 0.6607, 0.7516, 0.8241, 0.88, 0.9219, 0.9522, 0.9733, 0.9875, 0.9966, 1.002, 1.0049, 1.0061, 1.0063, 1.0058, 1.0051, 1.0042, 1.0034, 1.0026, 1.0019, 1.0014, 1 ) | response 0.5s, damping 0.85 — peak 1.006 | |
| --ease-smooth | linear( 0, 0.0501, 0.1598, 0.2889, 0.416, 0.5308, 0.6291, 0.7107, 0.7766, 0.829, 0.87, 0.9018, 0.9262, 0.9448, 0.9589, 0.9695, 0.9774, 0.9833, 0.9877, 0.991, 0.9934, 0.9952, 0.9965, 0.9974, 0.9981, 0.9986, 1 ) | response 0.5s, critically damped — never crosses 1, which is the point | |
| --ease-bouncy | linear( 0, 0.065, 0.2136, 0.3925, 0.5675, 0.7194, 0.84, 0.9283, 0.9875, 1.023, 1.0407, 1.046, 1.0435, 1.0369, 1.0288, 1.0207, 1.0135, 1.0078, 1.0036, 1.0008, 0.9991, 0.9982, 0.9979, 0.998, 0.9983, 0.9986, 1 ) | response 0.5s, damping 0.7 — peak 1.046, and it dips under 1 on the way back, which is what stops it reading as a single elastic snap | |
| --ease-spring | var(--ease-snappy) | Kept because it is a published name, and pointed at the spring that replaces it rather than deleted. |
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.
| Live | Token | Value | Purpose |
|---|---|---|---|
| --elevation-0 | none | — | |
| --elevation-1 | 0 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-2 | 0 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-3 | 0 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-4 | 0 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-5 | 0 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-overlay | var(--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.
| Live | Token | Value | Purpose |
|---|---|---|---|
| --radius-selector | var(--radius-sm) | checkboxes, radios, switches | |
| --radius-field | var(--radius-md) | inputs, buttons — anything you type into or press | |
| --radius-box | var(--radius-lg) | cards, dialogs, panels | |
| — | --focus-ring-width | 3px | — |
| — | --focus-ring | var(--focus-ring-width) solid var(--color-focus) | — |
| — | --focus-ring-offset | 2px | — |
Source: src/css/tokens/shape.css
Materials
A material is a surface that lets what is behind it show through. Apple's
platforms use them to say *this is a control layer, and the content is
underneath* — a toolbar, a sidebar, a sheet, a menu. The Human Interface
Guidelines put it in one sentence: «By allowing color to pass through from
background to foreground, a material establishes visual hierarchy to help
people more easily retain a sense of place.»
That is the whole reason a translucent bar is worth the trouble. It is not a
decorative frosted-glass effect: it is the only cheap way for a fixed bar to
tell you there is content scrolling under it rather than behind a wall.
FOUR THICKNESSES, AND THE TRADE THEY MAKE
The HIG names the trade explicitly, and it is the reason there is more than
one: «Thicker materials, which are more opaque, can provide better contrast
for text and other elements with fine features. Thinner materials, which are
more translucent, can help people retain their context.»
--material-ultra-thin the most context, the least contrast. Over media.
--material-thin a bar over a page that is mostly text.
--material-regular the default. Menus, popovers, sheets, navbars.
--material-thick where a lot of small text sits on it. Sidebars.
DERIVED, NOT DECLARED — AND WHY THAT IS NOT A BREACH OF RULE 5
«No runtime colour derivation» is about a theme's colours: a skin states
what its blue is rather than asking the library to lighten one. The alpha
here is not a colour, it is the material's thickness, which is structural and
the same in every skin — so each material is the skin's own raised surface
with a structural alpha over it, exactly the way elevation.css already
builds every shadow from --color-shadow and a structural opacity. A skin
that changes --color-surface-raised gets four correct materials for free,
and one that wants different ones restates these four names.
THE DEGRADATION LIVES HERE, NOT IN THE COMPONENTS
This is the part worth copying. A component opts in with two lines:
.navbar {
--navbar-bg: var(--material-regular);
backdrop-filter: var(--material-filter);
}
and never writes an @supports, a prefers-reduced-transparency or a
[data-legacy] of its own. Four conditions turn every material back into a
plain opaque surface, and they are decided once, here:
1. No backdrop-filter. A translucent bar with nothing blurring behind
it is not a weaker version of the effect, it is unreadable text over
whatever scrolled under it. So the base rule is opaque and the
translucency arrives only inside @supports. This is the dual-branch rule
from CLAUDE.md, and it is the case it was written for.
2. prefers-reduced-transparency: reduce. Settings ▸ Accessibility ▸
Display ▸ Reduce Transparency. A reader who has asked for this has asked
for exactly this, and the platform honours it by making every material
opaque rather than by making it slightly less see-through.
3. prefers-contrast: more. The HIG notes that materials change
appearance under increased contrast; the honest answer for a library that
cannot measure the content behind the glass is to stop guessing.
4. [data-legacy]. The library-wide opt out.
tests/e2e/materials.spec.ts pins all four, on real components, because
every one of them is a state no screenshot is taken in.
SATURATION IS NOT A FLOURISH
saturate(180%) next to the blur is what keeps colour alive through the
material. Blur averages the pixels behind it, and averaging colours pulls
them towards grey; without the boost a blue image under a white material
reads as a grey smudge, which is the opposite of «allowing color to pass
through». Apple's own materials do the same thing, which is why a photo under
a toolbar still looks like a photo.
| Live | Token | Value | Purpose |
|---|---|---|---|
| --material-ultra-thin | var(--color-surface-raised) | — | |
| --material-thin | var(--color-surface-raised) | — | |
| --material-regular | var(--color-surface-raised) | — | |
| --material-thick | var(--color-surface-raised) | — | |
| --material-filter | none | — | |
| — | --material-ultra-thin-alpha | 0.55 | — |
| — | --material-thin-alpha | 0.7 | — |
| — | --material-regular-alpha | 0.82 | — |
| — | --material-thick-alpha | 0.93 | — |
| — | --material-blur | 20px | — |
| — | --material-saturate | 180% | — |
Source: src/css/tokens/materials.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 used to say they were deliberately not a container component, on the
grounds that 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. Measured on
17 August 2026: eight consumers out of eight had written those two
declarations, every one of them as .wrap, and each had grown a third the
argument did not account for — padding-inline, the gutter. The class is
utilities/center.css; the custom property the argument feared is what makes
it worth having.
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.
| Live | Token | Value | Purpose |
|---|---|---|---|
| — | --container-narrow | 48rem | — |
| — | --container | 68rem | — |
| — | --container-wide | 80rem | — |
| — | --container-full | 100% | — |
| — | --container-prose | 46rem | The column for a page that is read rather than used, and the one number
here that is derived rather than chosen: with the border box this library
resets to, 46rem minus two --space-4 gutters is 44rem of text, which is
the running measure /guidelines/#measure publishes. |
| — | --target-size-min | 1.5rem | The 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. |
| — | --target-size | 2.5rem | A control you press: a button, a field, a select. |
| — | --target-size-compact | 2.25rem | One 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
--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.