# 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.
