# Smoothed virtual scroll (GSAP ScrollSmoother) — a lerped value everything reads, never raw scrollY

Learned 2026-09-02, same follow-up pass on humandone.com as the entry above.
Not a visual technique but a scroll-mechanics one: reproducing the measured
numbers of a scrub is not sufficient if the replica drives them off raw
`window.scrollY`, because the reference's numbers were all measured against a
smoothed value that never equals raw scroll except at rest.

- **The DOM shape**: `#smooth-wrapper` (`position:fixed; inset:0;
  overflow:hidden`) contains `#smooth-content`, an ordinary block that
  receives `transform: translateY(-N)` every frame. Native scroll still runs
  normally underneath, against a plain spacer element in real document flow
  sized to the content's natural height — so the scrollbar and
  `window.scrollY` both jump instantly on any input, wheel or programmatic.
- **What is smoothed is a separate internal value**, read by every
  ScrollTrigger-based scrub on the page instead of raw scroll — including
  scrubs on elements that live outside `#smooth-content` entirely. Confirmed
  on humandone.com: the hero's headline-zoom and opacity-fade (the entry
  above) sit on a `position:fixed` element that is a *sibling* of
  `#smooth-wrapper`, not a descendant, yet visibly obeys the same lag as
  everything inside it.
- **The practical failure this causes**: a replica that drives its scrubs off
  raw `scrollY` has every number right and still reads as broken to a real
  user, because raw scroll has no inertia while the reference's virtual
  scroll always does — a fast physical scroll or a large programmatic jump
  skips most of the visible animation frames instead of playing through them.
- **Measuring the smoothing constant.** At rest, dispatch an instant large
  jump (`window.scrollTo(0, N)`, N far from the current position — 3000px
  worked), then sample the driving value every rAF frame with a timestamp and
  fit an exponential decay `pos(t) = target − (target − start) · exp(−t/τ)`,
  solving τ from each sample past the first few (noisy/transient) and taking
  where it converges. On humandone.com this converged cleanly to **τ ≈
  114ms** after about 100ms of transient.
- **The recipe that reproduces it**: the same two-value split. Native scroll
  keeps driving the real, spacer-backed scrollbar; a `requestAnimationFrame`
  loop lerps an internal value toward `window.scrollY` every frame —
  `pos += (target − pos) · (1 − Math.exp(−dt/τ))` with real per-frame `dt` —
  and every scroll-driven visual, including ones outside the transformed
  wrapper, reads that lerped value, never raw `scrollY`.
- **Building the wrapper has its own gotcha**: its default
  `pointer-events:auto` sits on top of whatever it visually covers, even
  where the wrapper itself paints nothing (see the pointer-events entry in
  `live-extraction.md`'s *The parity gate* for the general lesson and the
  humandone showreel-button case it was paid for). Set `pointer-events:none`
  on `#smooth-wrapper` and re-enable `pointer-events:auto` only on its real
  content children, never on pure spacer children.
