scroll-motion — entrances earn attention once
Stage: Phase 9 — Motion - Reads: design/SYSTEM.md §motion, design/DIRECTION.md, design/SITEMAP.md, built sections - Writes: section entrance layer (Reveal wrapper, progress components, parallax/sticky moments)
Standard
Scroll motion directs reading order; it never performs for its own sake. First-grade means: reveals fire once, travel ≤12px by default, land in 400–700ms, and at most ~60% of sections animate at all — taste bans "staggered fade-up on every element". The empirical test: scroll every page top to bottom, then bottom to top — nothing re-triggers, nothing janks, above-fold content never waits for an entrance.
- Once, always.
viewport={{ once: true }}is the default. Re-triggering reveals on scroll-up reads as broken. - Small travel. ≤12px rise + fade is the default; 16–24px only when the intensity dial is turned up (motion-language). 100px fly-ups are 2015 scroll-library slop.
- Reading order. Elements within a section stagger 40–80ms in the order the eye should take, group total ≤ 600ms.
- Hero is exempt. The first viewport animates on load, not on scroll — it's already visible.
- Parallax is decoration-only. 10–15% displacement max, backgrounds and ornaments only, never body text or interactive elements.
- Named moves & the #1 hazard (
award-canon). A long page is Scroll-as-Journey — authored acts (rooms/worlds/phases) with density rhythm, dense walls alternating with rest, not a uniform stack. Binding scroll to one continuous spatial move (Scroll-as-Camera) is the top rung: DIRECTION-gated — in the DOM it is astickystage driven byuseScroll; inside a commissioned persistent canvas it belongs toultraweb:set-design, which consumes this skill'sscrollYProgressand never becomes a scroller. Either way it is the #1 scroll-jack hazard — layer motion on native scroll, never hijack velocity; native scroll position stays authoritative (keyboard, PageDown, find-in-page, and the footer all still reach), andpreventDefaultonwheelis never the mechanism. The parallax above is Fake-Depth Before Real Depth — subtle, transform-only, off under reduced-motion. - Smooth-scroll is gated and contract-bound. Lenis is the only sanctioned smooth-scroll layer, and only when DIRECTION.md makes smoothness a deliberate signature (it is an extra dependency over native scroll). Its contract is non-negotiable: under reduced motion it is never instantiated — early-return before
new Lenis(), not merely slowed; nav and fragment links route throughlenis.scrollToso anchors and Ctrl+F still land; native scrollbar-drag and keyboard (PageDown/Space/Home/End) stay live. A borrowed physics feel must never cost native browser behavior. - Horizontal scroll is contained, never document-level. A horizontal gallery is its own
overflow-x+scroll-snap-type: xregion withoverscroll-behavior-x: contain; keyboard, Tab, and trackpad scroll it natively. Hijacking the whole page's wheel to drive horizontal movement is the field's most-botched pattern — a named, banned anti-pattern. - The scroll engine is a ladder — three rungs, cheapest wins. Rung 1, the default: any effect that is a pure function of scroll/view position — reveals, progress bars, parallax scrub — takes
animation-timeline: view()/scroll(), which runs on the compositor, cannot jank, needs zero JS, and degrades to declarative CSS under reduced motion. Rung 2: Motion'suseScroll, only for spring-smoothing, velocity, or cross-element choreography (One Physics). Rung 3, DIRECTION-gated: a scroll-scrubbed SVG timeline — multi-path draw, morph, motion path sequenced together — is the one scroll effect neither rung can express, and it runs on anime.jsonScrollthrough ultraweb:animejs, which owns the install gate — DIRECTION-commissioned by name at motion intensity 3, since scrubbing and pinning are level-3 grants. Nothing below rung 3 may reach for a second engine, and no rung may reach for a renderer: a camera inside a DIRECTION-commissioned canvas is scene state, not a CSS property, soultraweb:set-designowns that install gate exactly asanimejsowns rung 3's — and drei'sScrollControlsis refused wherever DOM sections also scroll, because it zeroeswindow.scrollYand kills rungs 1 and 2 outright (per STACK.md). Every rung ships as progressive enhancement — wrap the CSS animation in@supports (animation-timeline: view())so the no-support state (Firefox stable is still flagged) is the correct static, fully visible layout. Never gate content visibility on the timeline. - Reduced motion: reveals collapse to opacity-only or nothing; parallax and sticky sequences disable entirely. A scrubbed SVG sequence obeys the same law from the other side: its reduce branch and its no-JS frame land the FINAL state — paths fully drawn, split text assembled — because a path left at full
stroke-dashoffsetis invisible content, not restraint.
Process
- Read SYSTEM.md §motion for reveal duration, easing, and stagger values; read DIRECTION.md for the motion stance (calm archetypes get fewer, subtler reveals).
- Map sections per SITEMAP.md: mark which reveal and which stay static. Data-dense sections, legal pages, and anything above the fold stay static.
- Build ONE
Revealclient wrapper and reuse it site-wide. Children passed as props stay server components — never convert a section to a client component for its entrance. - Mount
LazyMotion features={domAnimation} strictonce in a client provider near the root; usem.components everywhere below (per STACK.md,motion.throws under strict). - Add stagger groups where a section has 3–6 sibling items; beyond 6, reveal in batches or as one block.
- Parallax and sticky sequences only if DIRECTION.md supports them — max one sticky sequence per site.
- Verify in the browser (Playwright MCP): full scroll pass both directions, DevTools performance check for long tasks, reduced-motion emulation pass.
Patterns
Native scroll-driven timeline (CSS-first default) — reach for this before any JS reveal:
/* Default-visible: the reveal is enhancement only, never a visibility gate. */
.reveal { opacity: 1; }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal { animation: reveal-rise linear both; animation-timeline: view(); animation-range: entry 0% cover 40%; }
@keyframes reveal-rise { from { opacity: 0; translate: 0 12px } to { opacity: 1; translate: 0 0 } }
}
}
/* Progress bar: scroll(root block) instead of view() — scale-x a fixed strip. */
.progress { transform-origin: left; scale: 0 1; animation: grow-x linear both; animation-timeline: scroll(root block) }
@keyframes grow-x { to { scale: 1 1 } }
Above-fold elements start past their entry range, so fill both leaves them visible — no load-time flash. Register a typed custom property with @property when a timeline must interpolate a number or color (e.g. a --progress gradient stop). This is the default; the JS patterns below are the escalation for what CSS timelines can't express — springs, velocity, cross-element choreography. Rung 3 lives elsewhere: a scrubbed multi-path SVG timeline belongs to ultraweb:animejs, whose onScroll() defaults to sync: 'play pause' — threshold playback, not scrubbing. Scrubbing requires an explicit sync: true (or a number/ease), and the silent default is a "scrubbed" moment that just plays through on entry.
Reveal wrapper — the workhorse; everything else is exception:
"use client";
import { m } from "motion/react"; // app-level LazyMotion(domAnimation) provider required
import { dur, ease } from "@/lib/motion"; // motion-language's token mirror — no inline beziers/durations
export function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
return (
<m.div
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: dur.section, ease: ease.out, delay }}
>
{children}
</m.div>
);
}
margin: "-80px" fires the reveal after the section is meaningfully on screen, not at first pixel. dur.section/ease.out come from lib/motion.ts — motion-language's token mirror — so the numbers match SYSTEM.md §motion; never inline a bezier array or a raw duration here.
Stagger group — parent orchestrates, children inherit:
<m.ul initial="hidden" whileInView="show" viewport={{ once: true }}
variants={{ show: { transition: { staggerChildren: 0.06 } } }}>
{items.map((it) => (
<m.li key={it.id} variants={{ hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0 } }} />
))}
</m.ul>
0.04–0.08s per child. Variants work under domAnimation (STACK.md).
Scroll progress — long-form/editorial pages:
"use client";
import { m, useScroll, useSpring } from "motion/react";
export function ReadingProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 120, damping: 30, mass: 0.3 });
return <m.div style={{ scaleX }} className="fixed inset-x-0 top-0 z-50 h-0.5 origin-left bg-accent" />;
}
The useSpring smoothing is what separates it from a jittery scroll listener.
Parallax layer — element-scoped useScroll:
const ref = useRef(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const y = useTransform(scrollYProgress, [0, 1], ["-10%", "10%"]);
// <m.div ref={ref}><m.div style={{ y }} className="absolute inset-0 -z-10">…</m.div></m.div>
±10% total; 15% is the absolute ceiling. Oversize the layer (scale-110 or negative insets) so edges never show.
Sticky sequence — a relative h-[300vh] track with a sticky top-0 h-screen stage inside; drive phase opacity/position from the track's scrollYProgress via useTransform. Reserve for one showcase (see feature-sections for the layout); it must degrade to stacked static sections under reduced motion.
Contained horizontal scroll-snap — a rail that owns its own scroll, never the page's:
<ul className="flex snap-x snap-mandatory overflow-x-auto [overscroll-behavior-x:contain]"
tabIndex={0} role="region" aria-label="Case studies">
{items.map((it) => <li key={it.id} className="snap-start shrink-0 basis-[80vw] md:basis-[42ch]" />)}
</ul>
snap-x snap-mandatory + snap-start do the snapping; overscroll-behavior-x: contain stops an over-scroll from firing browser back-navigation. Trackpad, keyboard, and Tab scroll it for free — tabIndex={0} + role="region" + label make a non-interactive rail keyboard-reachable, and a rail of links/cards is already focusable. A wheel shim for mouse-wheel users is optional, scoped to the rail node, and releases at its edges so the page is never trapped:
function onWheel(e: WheelEvent) { // ref.addEventListener("wheel", onWheel, { passive: false })
const el = e.currentTarget as HTMLElement; // React's onWheel is passive — preventDefault no-ops there
const past = (e.deltaY < 0 && el.scrollLeft <= 0) ||
(e.deltaY > 0 && el.scrollLeft + el.clientWidth >= el.scrollWidth);
if (past) return; // at an edge: let the page scroll vertically
e.preventDefault(); el.scrollLeft += e.deltaY;
}
Smooth-scroll (Lenis) — DIRECTION-gated; the contract, not the feature, is the point:
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
export function SmoothScroll() {
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; // never instantiate
const lenis = new Lenis();
let id = requestAnimationFrame(function raf(t) { lenis.raf(t); id = requestAnimationFrame(raf); });
const MouseEvent) => {
const a = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[href^="#"]');
if (a) { e.preventDefault(); lenis.scrollTo(a.hash); } // anchors + Ctrl+F still land
};
document.addEventListener("click", onClick);
return () => { cancelAnimationFrame(id); document.removeEventListener("click", onClick); lenis.destroy(); };
}, []);
return null;
}
Lenis smooths native scroll (it doesn't transform a fake container), so scrollbar-drag and keyboard stay live by default; the two additions above cover the only things it would otherwise cost — reduced-motion users and fragment/anchor landings.
Anti-patterns
- Missing
once: true(grepwhileInViewwithoutviewport={{ once) — re-triggering entrances. whileInViewon every element — if more than ~2 reveal units animate per viewport-height, cut.y: 100(or larger) entrances — small travel only; grepy: 1\d\d.- Parallax on headlines, body copy, or anything clickable.
- Animating
filter/blur/box-shadowon scroll — paint storms; transform/opacity only. - Document- or window-level wheel
preventDefaultto drive a horizontal gallery — the field's most common scroll-jack; scope snapping to the rail's ownoverflow-xregion instead. - A smooth-scroll library instantiated without the reduced-motion early-return, or that leaves
a[href="#…"]/Ctrl+F unable to land — ship the Lenis contract or don't ship smooth-scroll. - A JS scroll listener or
useScrolldriving an effect that's a pure function of scroll position — a nativeanimation-timelinedoes it on the compositor; gate-performance flags these (two exceptions: the DIRECTION-commissioned animejs scrubbed-SVG moment, sinceanimation-timelinecannot drive multi-path timeline choreography; and the DIRECTION-commissionedset-designcamera, since a scene-graph camera is not a CSS property and no timeline can address it). - Page-level
useScrolldriving a section effect — always scope withtarget+offset. - Reveal delay > 0.3s on the first element of a section — the user is already waiting.
Worked example — Studio Norra, /work index case-study reveals
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.