Motion

Build and test animation to ELB's motion spec: pattern ids, motion tokens and hooks, reduced motion, once-per-visit entrances and budgets.

2026-11-07

The motion spec

Until 27 Oct 2026 design authored no motion, so everything that moved on the site was code-originated. ELB then delivered EuroLeague Motion Spec (draft for pilot; re-read as v1.0 on 3 Nov 2026), and it is now the source of truth: where the code and the spec disagree, the spec wins, and the change goes through the plan rather than being made ad hoc.

What Where
The binding rule .claude/rules/frontend/motion.md
The spec, digested .claude/reference/motion/elb-motion-spec.md
The build plan and log .claude/plans/elb-motion-spec.md
The tokens (Ghost layer) packages/ghost-core/src/theme/default.theme.ts (motion.*)
The tokens (club layer) packages/tokens-euro-league/src/motion.ts
The runtime primitives packages/ghost-core/src/motion/ (@v4/ghost-core/motion)
The e2e helpers apps/e2e/helpers/motion.ts

The spec describes a developer pack (MOTION.md, motion-tokens.json, motion-tokens.css) that has not arrived yet. Until it does, the digest is the record of every value, and a value the report does not state is marked ? in the digest — never guessed. When the pack lands it is vendored unchanged and wins over the digest.

Every animation names its pattern

The digest's §3 lists the patterns by id — P-REVEAL (a section fades up the first time it is seen), P-STICKY (the header hides on scroll down and the Game Centre score bar drops in), P-SKELETON, P-CASCADE (table rows, first 10 only), P-REORDER (sort and filter), P-COUNT (headline numbers count up once), P-BAR, P-CHART, P-LIVE, P-COUNTDOWN, P-HOVER / P-PRESS, P-CHIP, P-TAB, P-MENU, P-CAROUSEL, P-PAGE, P-SETTLE (a hero photo settles 103% → 100%), P-WAVE and others, with a "Code today" column saying what has been built.

A comment on the rule or hook names the id, the same way a theme leaf names its Token Studio binding:

// packages/ghost-basketball/src/utils/card-reveal.css.ts
from: { opacity: 0, transform: `translateY(${g.motion.distance.reveal})` }, // P-REVEAL — distance.reveal

A motion the spec does not describe is either a spec gap — raise it as a design question and record it in the plan — or it does not ship.

What the spec rules out

  • Ads never animate; their slot is reserved and still.
  • Only transform and opacity move. No width, height, top or margin — a value that changes continuously is scaled, never sized.
  • Logos fade at full strength or mask only; no repeating blink; nothing above three flashes a second.
  • No scroll-jacking, custom scroll physics or parallax on content.
  • Tables never count and never wobble: a row hover tints at once with no lift; numbers in a table cross-fade on change; only headline numbers count.
  • Nothing waits for motion. Live scores, sort results and filter results are in the DOM immediately; motion only decorates their arrival. Never delay a data update so an animation can finish.

Building a motion

Values are tokens, never literals

Durations, easings, staggers, distances and scales live on the global motion.* contract. Ghost's defaults are in default.theme.ts:

Group Leaves
duration fast 150ms, normal / base 200ms, moderate 300ms, slow 500ms, shimmer, countUp 800ms, barFill, chartDraw, liveFlash 1200ms, pageTransition 250ms, sheetExit, imageSettle 600ms, skeletonDelay 300ms, reorder
easing linear, standard, enter, exit, pop, emphasized (the spec's cubic-béziers)
stagger cards 60ms, list 40ms, chips 50ms, widgets 140ms, maxTotal 400ms, rounds 150ms
distance reveal 16px, revealSM 12px, hoverLift 4px, menuDrop 8px, drillPush 33.333%, tabSlide, nudge 40px
scale hoverImage 1.04, hoverPlay 1.1, press 0.98, settle 1.03

Euro League overrides only the leaves where the spec differs from Ghost, at override level 2 — today duration.slow is 400ms (tokens-euro-league src/motion.ts). Tuning the feel is changing a value there, never a component, and the commit says which pattern it tunes. A literal 120ms in a component defeats the whole point of the token file.

In CSS: the motion helpers

createTransition() and createAnimation() (from @v4/ghost-core/theme; in-package theme/helpers/motion.ts) read the motion.duration.* and motion.easing.* contract and bake in the prefers-reduced-motion: reduce override. MOTION_EASING and motionTokens expose the rest for a rule the helpers do not build. The old easing names in / out / inOut are aliases for exit / enter / standard.

Both helpers return an "@media" key. If your rule also declares "@media" (a mediaUp() override), merge the two blocks by hand — a spread followed by your own key silently drops the reduced-motion guard. The mechanics are in .claude/rules/frontend/vanilla-extract-conventions.md ("Motion & Transitions").

In script: the hooks

A pattern scheduled in script (a stagger, a count-up, a re-order) reads the same custom properties at run time, off the element that moves, so a Euro League override or a scoped competition theme moves script and CSS together. The primitives are exported from @v4/ghost-core/motion:

Primitive Use
motion, readMotionMs, readMotionEasing, readMotionToken read a motion.* token off an element
useRevealOnView, useRevealChildren, useRevealRail, RevealSections P-REVEAL entrances, once, when first seen
useSettle, settleAttrs P-SETTLE
useCascade (CASCADE_MAX_ITEMS) P-CASCADE
useFlipReorder, useTableRowMotion P-REORDER
useCountUp, CountUp P-COUNT, headline numbers only
RollingDigits P-COUNTDOWN / P-LIVE digit roll
useIndicatorSlide, usePillSlide P-TAB
useHeaderHide (HEADER_HIDE_AFTER) P-STICKY
useFadeOnChange, useExitPhase cross-fades and exits
entranceShape, entrancesOff the shared entrance keyframes, and whether entrances stand down

Reduced motion is a complete, instant page

Not "shorter animations": the final state, at once, with every loop stopped. Server markup is the final state; script arms an entrance before the first paint and plays it, so nothing is hidden without script. A hook that animates a value returns the final value immediately under prefers-reduced-motion: reduce. One implementation of the preference is shared by every component (packages/ghost-core/src/utils/reduced-motion.ts).

entrancesOff() is true under reduced motion or when <html> carries data-motion-final — the mark the Storybook story suite sets so play functions and axe read finished pages (see Testing).

Once per visit

Entrances and count-ups play the first time they are seen. Scrolling back never replays; a back navigation restores the place without replaying; a re-render with the same data does not replay. A data change — a new view, season or sort — is a new entrance.

Budgets

At most 12 concurrent animations on mobile and 24 on desktop (document.getAnimations()), CLS below 0.1, transform and opacity only. Cascades cap at 10 items (CASCADE_MAX_ITEMS) and 400ms total (stagger.maxTotal); past that, the rest arrive together.

Testing motion

Every pattern is tested from both sides (motion.md rule 9).

Layer What it proves
Unit the hook's final state, and its reduced-motion branch returning the final state at once (packages/ghost-core/src/motion/*.test.tsx)
e2e one frame after the trigger the element lists the expected animation, with the token's duration; under emulated reduced motion it lists none and is already in its final state
Story a story that shows the motion with a replay control, plus a play-less twin for the e2e

The e2e helpers

apps/e2e/helpers/motion.ts holds the shared readers:

  • animationsOf(locator, { subtree }) — the animations on an element one frame on (properties, duration, delay, play state);
  • reduceMotion(page) — emulate the reader's preference for the rest of the test;
  • runningAnimationCount(page) — the budget check;
  • recordAnimations / startedAnimations and recordPlays / playedAnimations — call before page.goto to record every animation the page starts, which is deterministic where a short animation would already have ended by the time animationsOf reads it;
  • entrancesSettled, finalStates, watchRolls / rolls, actAndRead.

apps/e2e/tests/ghost-content/article-motion.e2e.ts is a worked example: the article's entrances, P-SETTLE on the home hero, P-REVEAL in a rail, and nothing under reduced motion.

The e2e pins the Ghost theme, so it asserts Ghost's token values (for example duration.moderate 300ms, stagger.cards 60ms), not Euro League's.

Where the final state is read instead

  • The Storybook story suite marks <html data-motion-final> in apps/storybook/.storybook/vitest.setup.ts, so the entrance hooks stand down and no play function meets a card held at opacity 0, and axe never measures text mid-fade.
  • Visual-diff and story snapshots render with reduced motion: a frame is a final state.
  • The e2e never sets the mark — it is where the motion is tested.

An e2e that drives a story with a play function races it; give the e2e a play-less story (see E2E).

Keeping the reference current

Build a pattern → update the digest's "Code today" column in the same commit. Change a value → change the token and name the pattern it tunes. Design answers a decision → update the digest's §5 and the plan's decision log with the code that follows it.