# Scroll Motion

> Scroll-driven motion for ultraweb builds — CSS animation-timeline (view()/scroll()) as the default engine, section entrance reveals (once by default), stagger choreography, parallax discipline (10–15% max), sticky scroll sequences, scroll-linked progress, contained horizontal scroll-snap galleries, and DIRECTION-gated Lenis smooth-scroll with an accessibility contract. Invoke in the ultraweb motion phase (Phase 9) after sections are built, or whenever the user says "animate on scroll", "reveal sections", "fade in as you scroll", "parallax", "scroll progress bar", "sticky scroll section", "smooth scroll", "horizontal scroll section", or "the page feels static when scrolling".

- Skill: `blyatiful1/scroll-motion` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add blyatiful1/scroll-motion`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blyatiful1/scroll-motion/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: blyatiful1 (https://skillmd.com/u/blyatiful1)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blyatiful1/scroll-motion

---


# 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 a `sticky` stage driven by `useScroll`; inside a commissioned persistent canvas it belongs to `ultraweb:set-design`, which consumes this skill's `scrollYProgress` and 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), and `preventDefault` on `wheel` is 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 through `lenis.scrollTo` so 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: x` region with `overscroll-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's `useScroll`, 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.js `onScroll` through 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, so `ultraweb:set-design` owns that install gate exactly as `animejs` owns rung 3's — and drei's `ScrollControls` is refused wherever DOM sections also scroll, because it zeroes `window.scrollY` and 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-dashoffset` is invisible content, not restraint.

## Process

1. Read SYSTEM.md §motion for reveal duration, easing, and stagger values; read DIRECTION.md for the motion stance (calm archetypes get fewer, subtler reveals).
2. Map sections per SITEMAP.md: mark which reveal and which stay static. Data-dense sections, legal pages, and anything above the fold stay static.
3. Build ONE `Reveal` client wrapper and reuse it site-wide. Children passed as props stay server components — never convert a section to a client component for its entrance.
4. Mount `LazyMotion features={domAnimation} strict` once in a client provider near the root; use `m.` components everywhere below (per STACK.md, `motion.` throws under strict).
5. Add stagger groups where a section has 3–6 sibling items; beyond 6, reveal in batches or as one block.
6. Parallax and sticky sequences only if DIRECTION.md supports them — max one sticky sequence per site.
7. 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:

```css
/* 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:

```tsx
"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:

```tsx
<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:

```tsx
"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`:

```tsx
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:

```tsx
<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:

```tsx
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:

```tsx
"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 onClick = (e: 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` (grep `whileInView` without `viewport={{ once`) — re-triggering entrances.
- `whileInView` on every element — if more than ~2 reveal units animate per viewport-height, cut.
- `y: 100` (or larger) entrances — small travel only; grep `y: 1\d\d`.
- Parallax on headlines, body copy, or anything clickable.
- Animating `filter`/`blur`/`box-shadow` on scroll — paint storms; transform/opacity only.
- Document- or window-level wheel `preventDefault` to drive a horizontal gallery — the field's most common scroll-jack; scope snapping to the rail's own `overflow-x` region 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 `useScroll` driving an effect that's a pure function of scroll position — a native `animation-timeline` does it on the compositor; gate-performance flags these (two exceptions: the DIRECTION-commissioned animejs scrubbed-SVG moment, since `animation-timeline` cannot drive multi-path timeline choreography; and the DIRECTION-commissioned `set-design` camera, since a scene-graph camera is not a CSS property and no timeline can address it).
- Page-level `useScroll` driving a section effect — always scope with `target` + `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.

