Motion
Build and test animation to ELB's motion spec: pattern ids, motion tokens and hooks, reduced motion, once-per-visit entrances and budgets.
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
transformandopacitymove. 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/startedAnimationsandrecordPlays/playedAnimations— call beforepage.gototo record every animation the page starts, which is deterministic where a short animation would already have ended by the timeanimationsOfreads 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>inapps/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.