The document you measure is not always the document you see
This is the last post in a five-part series about recording a page while it moves. Capture models and clocks do not matter if the page does not move the way you think it does.
window.scrollTo(0, y) is the demo. Production sites are scroll-snap, Lenis-class smooth engines, overflow-owned layouts, and WebGL scenes that never exposed scrollHeight.
document.documentElement.scrollHeight is a good default. It is not a contract.
Marketing pages put the real scroll on an inner div with overflow: auto. Animation libraries put a hidden scroller in the tree and map it onto transforms. Canvas scenes consume wheel events and draw a camera. The window’s scrollY barely changes. Auto-stop based on “near scrollHeight - innerHeight” fires immediately, or never.
I detect overflow boxes and drive their scrollTop as well as the window. That over-fires on pages that have a small scrollable code sample. A short article with a modal or a widget can hijack the “page” height. The safer filter is: only trust a container that covers most of the viewport. I still miss some owners and still grab some widgets. It is a heuristic.
Wheel events have to look like a user. Libraries check hover before they honor wheel. Dispatching only on document is not enough. I target the element under the pointer (or the simulated pointer), bubble a wheel, and also hit the first canvas if the pointer is on an overlay. Isolated-world scrollTo never entered that canvas’s event path.
Smooth-scroll engines keep their own target in a requestAnimationFrame loop. After I jump to the top, they interpolate back to where they were. The first seconds of a capture show the page sliding away from y = 0. The counter is a freeze: keep forcing top until preparation delay ends, then release. At the bottom, stop sending deltas. Footer widgets and WebGL listeners bounce if you keep ticking. Syncing the engine’s internal offset to the real scrollY is a last poke, not a second scroller.
Snap is a feature until it is a fight
CSS scroll-snap will fight a linear scrollBy every frame. The page looks possessed: you ask for two pixels, it jumps a section, you ask again, it jumps back.
For ordinary auto-scroll I disable scroll-snap-type on the tree. The recording is a continuous move. That is the point.
For section-based sites, that disable is the bug. Those pages are the snap. I use a different driver: one synthetic wheel impulse, then silence, then another. Snap stays on. If the wheel does nothing, a deferred viewport-sized scrollBy runs on a later frame - never in the same frame as the wheel, or you double-apply. If several impulses produce no DOM progress, I stop. A capped impulse count exists so a broken page cannot run forever.
That second driver is not “smarter linear scroll.” It is a different machine: wait → impulse → wait. Mixing it into the rAF pixel loop is how you get both snap fighting and random jumps.
Height is a liar in both directions
Lazy load grows scrollHeight after you “finished.” If stop is already armed, I do not cancel it - pages that keep appending forever never end. If we are still in the settle window, I unlock and continue. A small threshold ignores animation jitter that is not real growth.
Banners shrink the document. You can lock onto a ghost bottom, then sit there while the real footer is still off-screen. Unlock if we are no longer near the new bottom.
“Near the bottom” as a percentage fails on long pages. A few pixels of footer is a fraction of a percent and still missing. I use a tight pixel tolerance, then a stagnation filet: if we asked to move and scrollY did not, the browser may have hit a wall because height was overestimated (sticky chrome). One snap to max, then believe the wall.
Smart pacing - pause on headings, wait for in-view images to decode - is extra policy on top of this, not a replacement. It still needs a real document height. On a canvas-only scene it has nothing to attach to.
Blind pages: do not auto-stop
If the measurable scroll range is tiny compared to the viewport, I treat the page as blind. Constant speed, wheel events for the WebGL path, no automatic end.
The user hits Stop. That is not a missing feature. Auto-stop would cut a three-minute WebGL journey at second zero because maxScroll was 40 pixels.
Impulse mode uses the same blindness test: no “we reached the bottom,” because there is no bottom to trust. It still stops on no-progress or a max impulse count so a dead page cannot spin.
Cinema cannot save this. A map needs a height. A 40-pixel document produces a poster of the first screen, then a camera with nowhere to go.
What I will not pretend to drive
Cross-origin iframes are other documents. I do not inject into every frame. If the product lives in an embed, outer-page auto-scroll is theatre.
Horizontal overflow is a different axis. The driver is vertical.
A hidden tab can stop painting. Capture goes black. Scroll may still be “working” in a world you cannot see.
Overlay cleanup that hides fixed nodes must spare canvases, main landmarks, and near-fullscreen layers. Otherwise you “clean” the WebGL scene you came to record.
The series in one line
A screenshot is a scan. Live video is a compositor diary. Cinema is a camera on a poster. Auto-scroll is a negotiation with whoever actually owns the scroll.
I built ScrollFlow as that negotiation on the user’s machine. It still loses to iframes, hidden tabs, sideways pages, and scenes that never exposed a scrollbar. Those losses are the spec, not the backlog.
Feedback welcome
If a site ignored window.scrollTo, I want that engine.
I turned this experiment into ScrollFlow, a Chrome extension for creating local auto-scroll captures of websites. It is free to try, and I’d genuinely value feedback from developers who work with difficult pages.
Top comments (0)