Sizes and motion, by mode
This is a spec: descriptive guidance, not a checkable rule. It carries no forbidden and instead pair, and no jig check detector applies to it directly.
The spec
01-modes.md names these tokens in each mode's profile and points here for the resolved values. They were not here: the option sets above cover type, spacing, radius and shadow, while control heights, row heights and durations lived only in tokens/mode.*.css. A reader following the pointer found nothing.
Unlike the option sets, these are not selections from a shared ladder — each mode states its own value, because density is the thing a mode *is*.
| Token | editorial | product | operator |
|---|---|---|---|
--size-control | 48px | 40px | 32px |
--size-control-sm | 40px | 32px | 28px |
--size-row | 56px | 48px | 36px |
--size-row-compact | 48px | 40px | 32px |
--size-icon | 20px | 18px | 16px |
--duration-fast | 150ms | 100ms | 75ms |
--duration-base | 250ms | 150ms | 100ms |
--duration-slow | 300ms | 200ms | 120ms |
--duration-ambient-fast | 3s | 0s | 0s |
--duration-ambient-base | 4.7s | 0s | 0s |
--duration-ambient-slow | 7.1s | 0s | 0s |
--measure-prose | 68ch | 60ch | 72ch |
--ease-out | cubic-bezier(0.16, 1, 0.3, 1) | cubic-bezier(0.16, 1, 0.3, 1) | cubic-bezier(0.2, 0, 0, 1) |
--ease-in-out | cubic-bezier(0.65, 0, 0.35, 1) | cubic-bezier(0.65, 0, 0.35, 1) | cubic-bezier(0.4, 0, 0.2, 1) |
Every mode declares every token name. A project with two modes shares one alias block (utilities.css) between them, and an alias for a token one mode never declares points at nothing on that mode's routes: on a real site, three utilities for row height and tabular figures were undefined on every editorial page. So each mode states a value for each name, even where the value is only a fallback: editorial rows are sized to its controls, though it rarely shows dense records, and ambient motion is 0s outside editorial (P-13), so a shared class resolves and nothing loops. check-tokens fails a mode file that declares a name another does not.
The ambient periods are a chord, not a ladder. The three interaction durations are a scale — fast, base, slow, pick by weight of change. The three ambient ones (P-13) are not: they exist so that several looping animations running at once can each take a *different* period. Their values are mutually prime in tenths of a second, so the layers do not re-align into a single visible pulse — 3 / 4.7 / 7.1s first coincide past the two-hour mark, where 3 / 4 / 6s would coincide every twelve seconds. Pick a different one per layer; which one carries no meaning beyond speed.
They are editorial only, and the — in the other two columns is an assertion: product and operator define nothing here, because a surface someone works in all day must not have anything moving on it that they did not cause.
Easing has a direction, and it is not a matter of taste. Motion in the physical world starts and stops under acceleration, so an element that arrives at rest should decelerate into place and an element leaving should accelerate away. That maps onto the tokens:
- Entering, or gaining attention — use
--ease-out. The element decelerates into its resting position, which is what makes it read as arriving rather than as being drawn. - Leaving, or losing attention — use
--ease-in-out. A pure ease-in would be the closer analogue, and we do not ship one: exits in this system fade or collapse in place rather than fly off screen, and a third easing token bought only that one case. - Moving within the screen, from one place to another:
--ease-in-out. The element is on screen at the start and the end, so it speeds up leaving its place and slows into the new one (G-161). - Never linear for anything that moves. Linear reads as mechanical because nothing physical moves that way. Colour and opacity are the exception — a simple curve is enough there, and often linear is fine.
The operator curves are tighter than the other two modes for the same reason its durations are shorter: a curve with a long tail makes a 100ms animation feel slower than it is.
--size-touch-target is 48px in every mode and is not a density decision. It is the minimum tap target — the hit area a finger needs — for anything that can be pressed. 48px is deliberately above both the 44px of the iOS guidance and the 24px minimum of WCAG 2.2. It is an accessibility floor, so it is excluded from the table above — there is nothing per-mode about it to resolve. The same is true of --focus-ring-width and --focus-ring-offset, which live in the brand file for that reason.
Layout sizes. Three tokens size a page's frame, where the others size what is inside it. A real site found none of them and made up its own three:
| Token | editorial | product | operator |
|---|---|---|---|
--size-container | 1280px | 1280px | 1280px |
--size-rail | 288px | 256px | 240px |
--size-containeris the page's frame: a rail, a readable column and a rail fit inside it, centred, and the viewport less--grid-margin-smbelow that.--size-railis a side column of navigation or filters, sized to hold labels at the mode's type size.--size-headeris one row of touch targets and its hairline, in every mode:calc(var(--size-touch-target) + var(--border-width-hairline)), 49px with the default brand. A fixed header's rails stick at it, their height is the viewport less it, andscroll-padding-topclears it.
--size-container is one value for the same reason the touch target is: it is not density. Past it, a header's last item drifts hundreds of pixels from where the text stops, and the page runs empty down one side. A product whose screens are wall-to-wall data raises it in its own layer, after the barrel, with the reason written beside it.
Where a layout switches is measured, not chosen. This system defines no breakpoint, and the four widths a page is judged at (360, 768, 1280, 1600) are checkpoints, not places the CSS changes. A layout switches where its content needs it: a header row where its labels fit (P-14), a third column where two rails and a readable column fit. That width is the project's, found by measuring, and it is recorded once, in the project's own layer:
@theme {
/* The four header labels and the wordmark need 504px, measured in the
site's own fonts; the row replaces Menu here. */
--breakpoint-nav: 540px;
}Name it --breakpoint-<what switches>, never after a device (a name like "tablet" says nothing about what changes), and write the measurement beside it. In Tailwind 4, @theme makes it the variant nav:. In plain CSS a custom property cannot be read inside @media, so the literal is repeated there, with a comment naming the token. jig probe --run reads these declarations and also measures one pixel either side of each, because a switch falls between the judged widths by design, and a range nobody measured is where a layout breaks: a real site's three-column frame appeared at 1216px, and 1024 to 1215 still got the phone arrangement.
Spacing selections. --spacing-card and --spacing-section pick from the shared ladder rather than stating their own values:
| Token | editorial | product | operator |
|---|---|---|---|
--spacing-card | --spacing-m | --spacing-m | --spacing-s |
--spacing-section | --spacing-xxl | --spacing-xl | --spacing-m |
- Kind
- Spec
- Section
- Design Tokens