Foundations

Motion

Motion tokens describe how surfaces move, not just how fast. They come in families — a duration, an ease, and any distances the movement needs — so a whole interaction can be retuned by editing the family rather than hunting transitions across components.

Every family respects prefers-reduced-motion. Under it the durations collapse to 0s and any offsets or scales reset to neutral, so the end state is reached with no animation. If you have reduced motion enabled, the values below will read that way.

One family is not like that, on purpose. fan is split into two groups — an arrangement group and a transition group — and reduced motion zeroes only the transition group. Zeroing the arrangement values would stack all twelve cards of a fanned deck dead-centre, because in a fan the geometry is the resting layout rather than a departure from it. Each group below states its own verdict, and ADR 0023 has the reasoning.

Panel

Chrome reveal, nav drawers and slide-over panels. Reduced motion collapses the duration to 0s so the panel appears instantly rather than sliding.

TokenValueUse for
--motion-duration-panel0.32sSlide duration for chrome reveal and drawer/panel transitions.
--motion-ease-panelcubic-bezier(0.4, 0, 0.2, 1)Standard ease-in-out for panel movement — no overshoot.

Lift

The pointer response of a raisable surface. The card rises and its shadow deepens; the focal image inside rises further and zooms slightly, so the product leads the surface it sits on. Hover-capable pointers (mouse) reach that end state on `:hover`; touch pointers reach the same end state via touch-down, but slower — a long, hard-decelerating creep that reads as elastic strain rather than a snap, settling back at the normal speed on release. Keyboard focus uses the same end state as hover, at every breakpoint. Reduced motion zeroes every offset, the scale, and both durations, leaving the card completely still.

TokenValueUse for
--motion-duration-lift0.28sDuration of the surface rise and focal parallax on hover/focus, and of release from a touch-down hold.
--motion-ease-liftcubic-bezier(0.34, 1.4, 0.64, 1)Short overshoot so the card settles rather than stopping dead — a spring approximation.
--motion-lift-offset-10pxHow far the surface itself rises — shared by the hover/focus lift and the touch-down hold.
--motion-lift-focal-offset-20pxHow far the focal image rises — larger than the surface, which is the parallax.
--motion-lift-focal-scale1.08Focal zoom on hover/touch-down. Tuned down from 1.3 so the image clears surrounding copy.
--motion-duration-lift-hold1.4sDuration of the touch-down creep toward the same lift offset — long, so the strain reads.
--motion-ease-lift-holdcubic-bezier(0.16, 1, 0.3, 1)Hard-decelerating (expo-out) ease for the hold: fast off the mark, then a long flat tail.

Reveal

How a group of items arrives. Each fades in and rises into place, one after another rather than all at once, so the eye is led along the row. The stagger is the point — it is what makes a list read as a sequence instead of a block appearing. Triggered when the group scrolls into view, once — or, for content at the top of the page that may only peek above the fold, as soon as any of it is on screen at load (`on-screen-at-load`). Under reduced motion the offset and stagger drop to zero and the items are simply present.

TokenValueUse for
--motion-duration-reveal0.5sHow long one item takes to fade and rise.
--motion-ease-revealcubic-bezier(0.22, 1, 0.36, 1)Firm ease-out — quick to start, settling gently, as befits something arriving.
--motion-reveal-offset20pxHow far each item rises as it fades in.
--motion-reveal-stagger100msDelay added per item, multiplied by its index. Total run is stagger × count, so keep it short.

Anchor arrival

What happens when a link sends the eye to something already on the page — a CTA anchoring back to the secondary hero's form. The page snaps to the target with a short, smooth scroll on the reveal ease, so it lands rather than drifts; the target then pulses three times per tap, on the panel ease: each pulse swells it a little and fades a 4px stroke in the selected colour (`--color-interactive-selected`, `--border-thick`) in and out around it, so it reads as a breath rather than a bounce. Driven by `useAnchorArrival` (src/system/), which reads these from the CSSOM. Under reduced motion the scroll becomes a jump, the pulse duration drops to zero and its scale to 1, so nothing moves.

TokenValueUse for
--motion-duration-anchor-scroll0.2sThe snap scroll to the link's target. Eased by --motion-ease-reveal.
--motion-duration-anchor-pulse0.6sOne pulse, out and back, once the scroll has landed; the whole run is this × the count. Eased by --motion-ease-panel.
--motion-anchor-pulse-count3How many pulses play per tap.
--motion-anchor-pulse-scale1.02Peak scale at the middle of each pulse, where the stroke is strongest. About 10px of growth on the 498px form card.

Tab dwell

How long an auto-advancing tab group holds one tab before moving to the next (SectionTabbedMedia, plan 28 slice 4). One token, read by two things that must never disagree: the progress ring's CSS animation and the component's JS advance timer, which reads it from the CSSOM rather than duplicating the number. A token and not an author control on purpose — `ControlsSchema` has no number kind, so retuning the dwell is a one-line change here. Under reduced motion the component switches auto-advance OFF entirely instead of shortening it; this value is deliberately NOT zeroed, because a 0s dwell would spin the tabs rather than stop them.

TokenValueUse for
--motion-duration-tab-dwell10sDwell per tab before the group advances, and the exact run of the depleting progress ring.

Logo scroll

The mobile logo row in SectionLogoWall scrolls on its own, as one endless line. Its run is this step multiplied by the number of logos, so a longer list moves at the same speed rather than faster. A logo longer than 3.75:1 is capped in width, so the speed in pixels per second for a given step depends on the logo mix. Pausable by a button, and by hover over or focus on the logos. Under reduced motion the row does not move at all and becomes a swipe row; this value is deliberately NOT zeroed, because a 0s run would flicker rather than stop.

TokenValueUse for
--motion-duration-logo-scroll-step2.5sTime for one logo to pass; the full loop is this × the logo count.
--motion-duration-logo-line-step3.7sThe Scrolling line layout, mobile: time for one logo to pass (slower than the Rows line).
--motion-duration-logo-line-step-desktop4.2sThe Scrolling line layout, desktop: time for one logo to pass; the loop is this × the logo count.

Fan

A deck of overlapping cards that follows the pointer — the first motion in the system driven by continuous input rather than triggered by state (CardFan; ADR 0023). Every card derives its position, rotation, depth-scale, dim and opacity in CSS from one number, `--fan-progress`, which the component drives from pointer events and settles by transitioning. That is why there is no animation dependency and why the duration and easing below are real tokens rather than decoration. This family is the only one in the system split into two groups, because it is the only one carrying layout values alongside motion values — and they must NOT be treated alike under reduced motion.

Arrangement — layout, not motion

Under prefers-reduced-motion: PRESERVED. Reduced motion does not touch a single value in this group, and must not.

Where the cards sit. In `lift` and `reveal` the offset token is a transient departure FROM the resting layout, so zeroing it correctly means “stay put”. In the fan the geometry IS the resting layout: zeroing the x-offset and depth-scale would stack all twelve cards dead-centre and destroy the component. These are layout tokens that live in a motion family for discoverability. If you have come here to make the reduced-motion block consistent with the other families, this is the paragraph that says don't.

TokenValueUse for
--fan-card-width308pxThe width the deck lays cards out at — 3 of 12 columns of a 1232px content width, the front card's width in Figma `792:3714`. It lives with the deck rather than with the card so the card stays width-agnostic and reusable outside a fan, and the container stays card-agnostic. Below desktop it becomes the container width less the layout margin, matching the Figma mobile frame.
--fan-offset-x-max277pxThe cap on the horizontal shift per step of offset from the front card (~0.675 card widths). The shift itself, `--fan-offset-x`, is not a fixed number: CardFan.module.css derives it per deck from the deck width, the card width and the visible card count, then clamps it to this cap. At the 1232px desktop deck it resolves to 231px, so the cap is never reached there. The two narrower tiers are container queries: a 58px cap on tablet, and on phone a floor of the layout bleed (80px).
--fan-lift-y0pxHow far each card sinks per step of offset. Zero at every tier since Figma `792:3714`: the cards sit level, centred on one line.
--fan-rotation-step0degRotation per step of offset. Zero at every tier since Figma `792:3714`: the cards are not angled.
--fan-depth-scale-step0.11How much smaller each card gets per step of offset — the depth cue. Draws the cards behind at 0.89x and 0.78x of the front card.
--fan-front-scale1Scale of the front card. 1 since Figma `792:3714`, which draws the front card at true size; a scale-up would draw its type larger than its tokens.
--fan-visible-count5How many cards read at full opacity. The next one out fades as it arrives or leaves — that fade is the endless wrap's only visible seam.
--fan-depth-dim-step0.10How much darker each card gets per step of offset, applied as `brightness()`. Desktop only — the two narrower tiers set it to 0. A depth cue like scale, which is why it is here and not in the transition group: a still deck still needs its front card to read as the front. The front card's offset is 0, so its dim is 0 and its text contrast is untouched.

Transition — motion

Under prefers-reduced-motion: ZEROED. Steps become instant; the deck jumps between positions.

How the deck gets from one card to the next. Both eases are damped springs authored as `linear()` curves, which is how there is genuine overshoot with no animation library. Under reduced motion the durations collapse to 0ms and CardFan additionally forces its instant mode, so the momentum projection cannot glide either — leaving a still, fully-separated deck that jumps. Nothing collapses and nothing overlaps illegibly.

TokenValueUse for
--motion-duration-fan-step480msA deliberate step: arrow key, flanking control, or clicking a card to promote it.
--motion-duration-fan-settle800msSettling onto a whole card after a drag or flick is released.
--motion-ease-fan-steplinear(…)Damped spring, ζ 0.75, peaking at 1.0284 — a 3% nudge past the target. Deliberate moves should feel answered, not thrown.
--motion-ease-fan-settlelinear(…)Damped spring, ζ 0.52, peaking at 1.1474 — real overshoot, ~15% past the target before it comes back. This is the curve a flick lands on.

Lift in place

Hover the card. The surface rises by --motion-lift-offset, the tray rises by --motion-lift-focal-offset and zooms by --motion-lift-focal-scale. Keyboard users get the same response via :focus-within when they tab to the card’s CTA, at every breakpoint.

On a touch device, touch and hold the card instead of hovering it: it reaches the same offset, but slowly, over --motion-duration-lift-hold on a hard-decelerating --motion-ease-lift-hold curve — fast at first, then creeping, which reads as elastic strain rather than a second, deeper state. Release settles back at the ordinary --motion-duration-lift regardless of how long the hold ran. Hover and touch-down are gated to hover: hover and hover: none respectively — every pointer covered once, never twice.

Build muscle

High-protein meals to support training and recovery.

Reveal in place

Scroll this row into view. Each card fades in and rises by --motion-reveal-offset, offset from the one before it by --motion-reveal-stagger. It runs once. Reload the page and scroll back down to see it again.

This is a shared primitive (app/globals.css), not a card or carousel behaviour — any container of items can adopt it:

The primitive itself has no opinion on breakpoint — it is a consumer decision. CardLayout runs it at EVERY breakpoint (plan 22 reversed an earlier desktop-only rule): the staggered arrival plays on touch too, coexisting with the touch-down lift because the reveal animates the track item while the lift transforms the card surface inside it — the two never fight over the same transform.

Nine consumers share it today: this demo’s CardLayout, CardFan, SectionCardQuotes, SectionCardGrid, SectionCardStats, SectionCardPhotos and SectionMediaPills arm it on scroll into view; the secondary SectionHero’s form arms it on screen at load (below); SectionTabbedMedia arms it on scroll into view for a panel’s first arrival, then replays it on every subsequent tab swap via a keyed remount.

  • The intended way to consume the family is useRevealGroup (src/system/useRevealGroup.ts) — a seam over useRevealOnScroll that returns containerProps, itemProps(stagger, className?) and RevealNoscript, so a new consumer wires all five pieces below without re-deriving them.
  • Put data-reveal="pending"|"in" on the container, driven by the useRevealOnScroll hook underneath the seam (src/system/useRevealOnScroll.ts) — it watches the container and flips to "in" the first time it scrolls into view.
  • The trigger is scroll into view by default. Pass "on-screen-at-load" as the third argument for content at the top of the page that may only peek above the fold — a short phone, or the browser’s bars showing: if any of it is on screen when it is first observed, it reveals straight away instead of waiting for the first scroll.
  • Put the .reveal-item class on each child that should fade and rise.
  • Set --reveal-index per child (its position in the group) so the CSS stagger delay is index × --motion-reveal-stagger.
  • Pair it with a <noscript> block that forces .reveal-item back to visible — without JS the observer never fires, so the container never leaves "pending".

Fan, driven by the pointer

Drag the deck sideways and let go — the release projects your velocity forward and settles onto a whole card on --motion-ease-fan-settle, which overshoots by about 15% before coming back. Step it with the arrows beside it or with ←/→ once the deck has focus and you get the calmer --motion-ease-fan-step instead, a 3% nudge. Click any card behind the front one to promote it. The front card does nothing.

There are twelve cards here and no ends: the geometry is progress mod 12, so the deck wraps forever, and the only visible seam is the trailing card fading out as a new one arrives. Nothing in this is a scroller and nothing is an animation library — it is one registered custom property, --fan-progress, being transitioned.

Turn reduced motion on and try it again. The deck stays exactly as fanned, as dimmed and as separated as it is now — it simply jumps between positions instead of gliding. That is the two-group split doing its job: if the arrangement group were zeroed alongside the durations, all twelve cards would be stacked dead-centre right now.

Card 1

Drag the deck, or click a card behind the front one.

Card 2

Drag the deck, or click a card behind the front one.

Card 3

Drag the deck, or click a card behind the front one.

Card 4

Drag the deck, or click a card behind the front one.

Card 5

Drag the deck, or click a card behind the front one.

Card 6

Drag the deck, or click a card behind the front one.

Card 7

Drag the deck, or click a card behind the front one.

Card 8

Drag the deck, or click a card behind the front one.

Card 9

Drag the deck, or click a card behind the front one.

Card 10

Drag the deck, or click a card behind the front one.

Card 11

Drag the deck, or click a card behind the front one.

Card 12

Drag the deck, or click a card behind the front one.

Card 1