Files
ai-for-dummies/.agents/skills/motion/references/scroll-driven.md
T
Marcos Paulo 79920c9e6c
verify-and-publish / gate (push) Successful in 7m4s
verify-and-publish / publish (push) Has been skipped
feat: give the chapters motion, diagrams, and retrieval practice
Three strands of work on the chapter surface, all reading from the same
constraint: this site ships no runtime dependencies, so every effect below
is native CSS or an .astro component.

Motion (src/styles/motion.css, DrawRule.astro). A scroll-driven layer of
reveals, hero parallax, section depth, and a hairline that paints itself
along its path, scrubbed by `animation-timeline: view()` — 0KB against
lottie-web's ~60KB gzipped on the main thread. Every scrubbed rule sits
inside `prefers-reduced-motion: no-preference` and `@supports`, so a
Firefox reader or an opted-out one gets the complete static page rather
than one with holes in it. Ranges key to `cover 40%`-`cover 75%` where the
motion is meant to be watched: `view()` ranges key to first visibility,
which on a 2600px page is long before anyone is reading the section.
`.agents/skills/motion/references/scroll-driven.md` records the technique
and the two ways `pathLength` normalisation was broken while building it.

Diagrams (StepFlow.astro, WorktreeMap.astro). The `.steps` stack on
/skills/, /models/, and /agents/ becomes a numbered flow with connectors,
and /agents/ grows the worktree map it was describing in prose — reusing
the `trees` collection rather than a second set of strings. The map's
static variant is gated one class deeper than full-guide's page styles, so
the interactive copy renders byte-identical. Connectors are pseudo-elements,
not SVG: a stretched path desynchronises its own dash pattern, and a
straight line does not need one. Mermaid was considered and rejected at
~1MB of runtime.

Retrieval practice (/rules/, /skills/). A "check yourself" section of
native `<details>` question/answer pairs plus a citation row, both bilingual
through the existing `data-copy` toggle, and both JavaScript-free.

Two audit blind spots surfaced and are closed rather than worked around:
`build.inlineStylesheets: 'never'`, because Astro inlined sheets under ~4kB
and audit-ui.mjs reads its colour and size baseline from dist/_astro/*.css;
and `--columns` declared in the grid components, because an element-level
custom property is not a declaration the audit can resolve.

legacy/styles/skills.css is renamed and imported for its side effect. Inside
a *page*, `?url` resolves to that page's own CSS chunk whatever file it
names, so the link pointed at the wrong asset and the sheet was emitted but
never loaded — the package preview had been rendering unstyled and
overflowing since the Astro cutover.

verify.mjs gains 5 assertions for the recall sections and their Portuguese
copy: 89 now, against the 84 baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:04:08 -03:00

8.7 KiB

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:

@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

/* 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.cssanimation: 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.shaudit-ui.mjs fails the build on horizontal overflow, which a mis-sized decorative SVG will cause.