Files
claude/skills/site-motion/SKILL.md
T
bastien ba14b5ea03 feat(skills): site-motion, site-level scroll and transition choreography
Personal skill distilling the MengTo motion pack invariants (LRN-141):
gates first (reduced motion renders final states, content visible without
JS, compositor-only, offscreen pause), one smooth-scroll engine with the
Lenis/ScrollTrigger sync, Astro ClientRouter lifecycle, numbered recipes
(reveal, scrub, sticky stack, video and image scrub, TreeWalker split,
progressive blur, marquee, WebGL budgets), upstream pitfalls, checklist.
Routed into the Build UI chain of CLAUDE.global.md and lib/design-gate.md.
2026-09-28 01:40:01 +02:00

9.4 KiB

name, description, argument-hint, allowed-tools
name description argument-hint allowed-tools
site-motion Site-level motion choreography: scroll engine choice, page-transition rules, and pin/scrub sequencing across a whole page or Astro route — not one component's hover or enter/exit (that's design-motion-principles or emil-design-eng). Distills the invariants behind smooth scroll, scroll storytelling, sticky card stacks, video/image scrubbing, and WebGL hero lanes into gates, numbers, and pitfalls. Triggers: "site mouvementé", "scroll storytelling", "smooth scroll", "hero WebGL", "transitions de page", "page transitions", "Lenis", "ScrollTrigger", "Awwwards". <page, section or route to choreograph>
Read
Edit
Write
Bash
Grep
Glob

Site motion — page-level scroll and transition choreography

Invariants, not machinery: numbers and gates that hold across whichever scroll library the project already runs (LRN-141). Defaults follow rules/web-building.md; this skill only adds the site-level layer on top of it.

When to use this, not the component skills

  • One component's hover, tap feedback, or enter/exit → the target feels small and self-contained → skills-external/design-motion-principles/SKILL.md (component motion, frequency/duration framework) or skills-external/emil-design-eng/SKILL.md (taste, polish).
  • The choice is page-wide: which scroll engine, whether to pin a section, how a route transition should morph, how a WebGL hero should degrade → this skill.
  • Linting already-shipped motion against anti-slop rules → skills/impeccable/reference/animate.md (impeccable detect) as the deterministic floor, or design-motion-principles' own audit workflow (skills-external/design-motion-principles/workflows/audit.md).
  • Non-motion defaults (fonts, color, spacing, the public-site checklist) stay in rules/web-building.md — read it, don't restate it here.

Gates first (fail closed, not invisible)

  • Under prefers-reduced-motion: reduce, every animation renders its FINAL state, never a shortened version of the same tween — jump, don't rush.
  • Content is visible with JavaScript disabled; no permanent opacity: 0 gated only by a script that might fail to run.
  • Any html.js (or .has-motion) class that hides the pre-animation state is added only AFTER gsap.registerPlugin(...) and the reveal setup both succeed — never before. An error between the two otherwise leaves real content stuck invisible with no JS path left to reveal it.
  • Animate compositor-only properties: transform, opacity, short-lived filter/clip-path. Never a layout property during scroll.
  • will-change only while an element is actively animating; drop it once the animation ends.
  • Every RAF loop, CSS animation, and WebGL render loop pauses when its section leaves the viewport and resumes on re-entry.
  • The first-viewport CTA is never covered by a preloader.
  • No preloader on a fixed timer — tie its exit to real load state.

Engine choice

  • Exactly one smooth-scroll engine per page. A second scroller (native plus Lenis, or two libraries) fights the first over wheel/touch input and desyncs from anything watching scroll position.
  • Lenis feeds ScrollTrigger through gsap.ticker, with gsap.ticker.lagSmoothing(0) — without it, a tab-switch catch-up jump throws scrub position out of sync with the visuals.
  • Reach for CSS animation-timeline: scroll() / view() first when the effect is a plain progress mapping with no pin and no cross-timeline coordination. GSAP/Lenis earn their cost on pin, scrub-linked sequencing, or a timeline shared across sections (BDR-005: motion is the default library; GSAP is allowed once the project already uses it or the effect needs pin/scrub).

Astro lifecycle (ClientRouter)

Route swaps replace the DOM without a full reload, so DOMContentLoaded fires once and never again.

  • Init scroll engines, ScrollTrigger instances, and RAF loops on astro:page-load — it fires after every swap, including the first.
  • Teardown on astro:before-swap: kill ScrollTriggers, stop the scroller, cancel RAF, disconnect observers, before the old DOM is replaced — otherwise the previous route's loop keeps running detached.
  • transition:persist on a WebGL canvas or renderer that should survive the swap instead of losing its context every navigation; pair it with hooks that update the scene, not rebuild it.
  • transition:name for element morphs (hero image to detail image) across routes; leave unrelated elements unnamed to avoid accidental cross-fades.
  • Test every scene with JavaScript on and off — without it, the ClientRouter falls back to a normal navigation and the page still has to make sense.

Recipes (the numbers)

  • Reveal: trigger at top 82%, once; ease the entrance tween itself, never the trigger point.
  • Scrub scenes: scrub: 0.8 to 1.4, ease: "none" on the scroll-driven tween — ease the child tweens inside it instead, so the outer timeline stays scroll-linear while the content still feels eased.
  • Sticky card stack: the receding card scales to 0.92 + i * 0.015 (i = card index), scrubbed from the next card crossing top 78% to top 24%.
  • Story pacing: budget 0.7 to 1.8 viewport heights of scroll per story beat — below that it reads as a flicker, above it as a stall.
  • Video scrub: encode with ffmpeg -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart — a keyframe every 8 frames so scrubbed currentTime seeks land on-frame.
  • Image sequences: preload the current frame first, prefetch neighbors, and cancel stale in-flight requests on a fast scroll so a slow response can't paint an out-of-order frame.
  • Word-level reveal on marked-up text (links, em, strong inside): walk it with TreeWalker, wrap non-whitespace tokens in spans, keep the original text and its inline markup intact.
  • Progressive blur: stack backdrop-filter layers from 0.5px to 64px, each masked to a 12.5% band of the gradient; add the -webkit-backdrop-filter prefix for Safari; cap the band at 12% of viewport height from the top edge, 65% from the bottom.
  • Marquee: duplicate the track, animate translateX(-50%) linear, mark the duplicate aria-hidden, pause the track while its section is offscreen.
  • Magnetic and cursor motion: drive with gsap.quickTo() so a pointer move updates the existing tween instead of creating a new one per event.
  • WebGL hero, one lane per page:
    • A pricing or checkout page keeps the WebGL lane decorative only.
    • Build the poster fallback first, enhance after — the poster is the page when WebGL fails or the tab throttles.
    • Handle webglcontextlost/webglcontextrestored explicitly.
    • Mobile budget: DPR 1.25 to 1.5, 150k to 300k visible triangles, 50 to 90 draw calls.
    • Track two progress values: exact scroll progress for navigation and ARIA state, a damped one for the camera — the camera can lag, the nav state cannot.
  • Leak census: sample document.getAnimations() before and after a route round-trip. A count that grows across astro:page-load cycles means a teardown is missing, not that more is animating.

Upstream pitfalls

  • clearProps combined with a visibility override in the same reduced-motion call clears that override before the CSS-hidden class it was meant to defeat ever lifts — text stays hidden. Clear props or drop the hiding class; don't do both in one step.
  • registerPlugin running after the html.js gate is already set: see Gates above, this is the concrete failure it prevents.
  • A per-frame increment (phi += 0.01) with no delta-time factor ties rotation speed to frame rate — the same globe spins at a different real-world speed on a 30 Hz and a 120 Hz display.
  • A WebGL context torn down and rebuilt on every resize event: expensive, and rapid resizing (mobile keyboard, orientation flicker) can trigger a real context loss. Resize the renderer/camera in place; debounce first.
  • A preloader gated on a fixed timer exits early on a slow connection and late on a fast one; gate it on real asset load state instead.
  • aria-label set on a <p> after flattening it to plain text: any inline link, em, or strong it contained is gone for assistive tech, which now hears the label instead of the real content. Use TreeWalker splitting on paragraphs with inline markup; aria-label is fine only on a plain-text heading with nothing inside to lose.
  • Critical CSS starting elements at opacity: 0 with no fallback: if the script errors, is blocked, or never loads, the section stays invisible forever. Pair every such rule with the gates above.

Verification checklist

  • Reload each scene with reduced motion forced on: final states, no smooth scroll, no pinning.
  • Disable JavaScript: every section is present and readable in order.
  • Scrub through fast and reversed: same scroll position always yields the same visual state.
  • Resize mid-scene, including orientation change on a real WebGL scene.
  • Navigate the Astro route twice: document.getAnimations() count returns to baseline, no duplicate ScrollTriggers, no orphaned RAF loop.
  • Throttle to a slow connection: preloader and poster still make sense, CTA is reachable immediately.
  • Tab away mid-scrub, come back: no jump beyond what lagSmoothing(0) already accounts for.
  • Run impeccable detect on the touched files as the anti-slop floor.