# Reference: scroll-driven motion Everything scrubbed by scroll position on this site — reveals, parallax, section depth, line draw. Read before adding a class to `src/styles/motion.css` or reaching for a scroll library. ## Why this is the whole toolkit `.agents/rules/animation.md` forbids animation libraries: the site's thesis is having no runtime dependencies. That rules out Lottie, Rive, GSAP ScrollTrigger, Motion One, and AOS. It is not a hardship — for the motion this site does, the native features are also the better engineering: | Approach | Runtime | Thread | | ---------------------------- | --------- | ----------------------------- | | `animation-timeline: view()` | 0 KB | compositor | | lottie-web | ~60 KB gz | main, `requestAnimationFrame` | | dotLottie | ~50 KB gz | main | | Rive (WASM) | ~200 KB | main + WASM | Lottie earns its place for cinematic, multi-layer, path-morphing artwork drawn by a motion designer. Reveals, drifts, and a hairline drawing itself are not that. If a future request genuinely needs frame-by-frame artwork, it is a rule change to argue for in `.agents/rules/animation.md`, not a quiet `pnpm add`. ## Support and the two gates Chromium 115+, Safari 26+. Firefox has it behind a flag. That is fine, because every scrubbed rule sits inside two nested gates: ```css @media (prefers-reduced-motion: no-preference) { @supports (animation-timeline: view()) { /* scrubbed rules only */ } } ``` The `@supports` gate is not decoration. Anything that hides an element at rest — `opacity: 0`, `stroke-dashoffset: 100` — must live **inside** it. Declared outside, a Firefox reader gets a permanently invisible element, because the animation that would have revealed it never runs. ## Ranges `animation-range` is the duration; there is no `animation-duration` on a scrubbed animation. Defaults are not what you would guess: bare `animation-range: normal` means `cover 0% cover 100%`, edge-to-edge for the element's whole visible life, which is rarely the effect wanted. Always state the range. - `cover` — first pixel enters → last pixel leaves. The full journey. - `contain` — only while fully visible. **Flips meaning** when the element is taller than the viewport: it then means "the element fills the scrollport". This is the usual source of confusion; prefer `entry`/`exit`/`cover`. - `entry` — starts entering → fully inside. Use for one-shot arrivals. - `exit` — starts leaving → fully outside. Use for departures. Ranges may be mixed at the two ends (`entry 15% cover 55%`). Legal, and harder to debug — do it only when a single named range cannot express the moment. Always `both` for fill mode. Without it the animation snaps back to its `from` state when scrolled past, and holds nothing before the range begins. Always `linear` easing. The scroll position _is_ the timing function; a curve here double-eases and reads as drag. This is the one sanctioned exception to "never `linear`" in `.agents/rules/animation.md`; time-based motion still uses `--ease-out`. ### Ranges key to first visibility, not to reading position The trap that produced the first version of every animation on `/skills/`. `entry` begins the moment one pixel crosses the bottom of the scrollport. On a 2600px page, a section's `entry` phase is over while the reader is still on the hero — the motion is finished before anyone is looking at it. Measured on the skills chapter: an `entry 15% cover 55%` line draw ran from scrollY 190 to 654, and the section it belonged to did not reach the top of the viewport until 599. For motion meant to be _watched_, target the band where the subject sits around the middle of the screen: **`cover 40%` to `cover 75%`**. For motion meant to be _over before reading starts_ — a reveal — `entry` is right, and that is why `.reveal` keeps it. Check this, do not eyeball it. Print `getAnimations()[0].effect.getComputedTiming().progress` at several `window.scrollTo` positions and confirm progress crosses 0→1 inside the range where the section's own top is between roughly 0 and 400px. ### Amplitude has a floor A drift spread across an element's whole visible life is mostly off-screen. A 22px/6px parallax pair measured out at 3-10px of separation across the window where the section was actually read — arithmetically present, perceptually absent. Motion that cannot be seen is not restraint, it is dead code. The pair is 38px/10px for that reason, ~25px of separation in the reading window. ## What this site's classes mean | Class | Range | For | | ----------------------------- | --------------------- | ------------------------------------------ | | `.reveal` | `entry 0% entry 45%` | a section rising into place, settled early | | `.parallax-near` / `-far` | `exit 0% exit 100%` | hero pieces, which start already on screen | | `.depth-slow` / `.depth-fast` | `entry 0% exit 100%` | the two columns of a mid-page section | | `.draw-line path` | `cover 40% cover 75%` | a hairline painting itself, while watched | The hero pair and the section pair differ in range for a reason: a hero is on screen at load, so only its exit is watched; a mid-page section is watched from first pixel to last. Parallax rates go the **same** direction with different magnitudes. Opposite directions read as the page coming apart, not as depth. ## Line draw ```css /* inside both gates */ .draw-line path { stroke-dasharray: 100; stroke-dashoffset: 100; animation: line-draw linear both; animation-timeline: view(); animation-range: entry 15% cover 55%; } @keyframes line-draw { to { stroke-dashoffset: 0; } } ``` `pathLength="100"` normalises the path to 100 user units whatever its real geometry, so the dash numbers above are constant with no JS measurement step. `fill: none` on the path, or the shape fills in behind the stroke. `stroke-dasharray` and `stroke-dashoffset` must be **equal** at rest. Offset smaller than the array → the line starts part-drawn. Larger → a gap that never closes. ### Never let user space and device space diverge `pathLength` normalises in **user space**. Anything that makes the rendered geometry a different shape from the user-space geometry desynchronises the dash pattern, and the line renders as a painted run, a gap, and a floating fragment. Two ways to cause it, both hit while building `DrawRule.astro`: - `preserveAspectRatio="none"` on a stretched viewBox — each axis scales by a different factor. - `vector-effect="non-scaling-stroke"` — Chromium spaces dashes in device space while `pathLength` normalises in user space. Uniform scaling does not save you here. The fix is to remove the scaling rather than compensate for it: build the viewBox at the rendered pixel size and emit the path in those units. Scale factor 1, one space, `stroke-width: 1` is 1px, and neither attribute is needed. Verify by counting painted runs, not by looking — a correct draw has exactly one at every progress value. Sample points along `path.getTotalLength()`, convert the computed `stroke-dasharray`/`stroke-dashoffset` out of the normalised 100 units, and count transitions from gap to dash. `stroke-dashoffset` is the site's one sanctioned exception to transform/opacity-only. It forces a repaint, not a layout, and the subject is a hairline — but do not extend the exception to filled artwork. ## Straight lines do not need SVG A plain vertical or horizontal rule that grows is `transform: scaleY()` with `transform-origin: top` on a 1px div. Fully composited, no repaint, no SVG. Reach for the dash technique only when the path bends. ## Printing Print freezes animations at their current time, so a section that never entered prints invisible and a half-drawn line prints as a fragment. Every scrubbed class needs an entry in the `@media print` block at the foot of `motion.css` — `animation: none` for transforms, plus `stroke-dasharray: none` for a drawn line, since undoing the animation alone leaves the dash pattern in place. ## Checking it - Chrome DevTools → **Rendering → Emulate `prefers-reduced-motion: reduce`**. The static page must be correct and complete, not a page with holes in it. - Firefox, or Chrome with `about:config`-style support disabled: same test for the `@supports` fallback. - `scroll-driven-animations.style` has a range visualiser; there is a Scroll-Driven Animations debugger extension for DevTools. - `bash .agents/scripts/gate.sh` — `audit-ui.mjs` fails the build on horizontal overflow, which a mis-sized decorative SVG will cause.