Every scroll animation you have admired this year is a flipbook. The scrollbar is the thumb.
John Barnes Linnett patented that in 1868 as the kineograph. Apple's AirPods Pro page ships it today at 2560x1440, and someone rebuilt it in a pen. Every frame already drawn, every frame already there, and the reader can stop on frame 41 and stare at it.
The obvious way to build it is an <img />, a scroll listener, and some offset arithmetic. Wrong, and clunky. Linnett's toy works for three reasons, and each one maps to a rule:
-
canvas, neverimg. Thecanvasrepaints far faster than the image tag, which was built for static views. This is the paper stock. Turning a page has to cost nothing. -
Preload every asset. Mandatory. Otherwise the browser fetches each frame the moment it is needed and the thing stutters. A flipbook whose pages arrive one at a time is not a flipbook. It is a slideshow.
-
Never hijack
scroll. Fix thecanvasto a tall container and read the scroll position instead. The thumb belongs to the reader. Take it away and every user feels the theft, even when they cannot name it.
And the price, honestly:
-
You are fetching hundreds of images. The compressed gif above is 5MB and looks like a fax. The 174 source frames behind the demo are 8MB, at full 2560x1440
jpeg. Sixty percent more bytes, an order of magnitude better picture. This is video minus the codec, so check the connection before committing a user to the download. -
You need the photos first. A high-quality sequence is a prerequisite, not a detail, and no amount of
requestAnimationFramerescues a bad one.
Apply all five and you get this.
The markup
<!-- Preload -->
<head>
<!-- Repeat for as many frames as needed -->
<link
rel="preload"
as="image"
type="image/jpeg"
href="/asset/frame/001.jpeg"
/>
</head>
<!-- Markup -->
<div class="container">
<canvas></canvas>
</div>The render
That is real code from this site. It drives the demo above.
const context = canvas.getContext('2d')
const image = new Image()
const frameMax = 174
$container.addEventListener('scroll', () => {
const scrollTop = $container.scrollTop
const maxScrollTop = $container.scrollHeight - window.innerHeight
const scrollFraction = scrollTop / maxScrollTop
const current = Math.floor(scrollFraction * frameMax)
const frame = Math.min(frameMax - 1, current)
requestAnimationFrame(() => {
image.src = createFrameRoute(frame + 1)
context.drawImage(image, 0, 0)
})
})requestAnimationFrame is the whole trick on the JavaScript side. It hands the paint to the browser's own frame budget, so the sequence stays smooth while the main thread is busy with something else.
The stick
.container {
/* The long-height container */
height: 600vh;
position: relative;
}
canvas {
max-height: 100vh;
max-width: 100vw;
/* Keep the canvas in front of the container while scrolling */
position: sticky;
top: 0;
}600vh is the length of the flipbook. Shorten it and the animation runs fast and cheap. Lengthen it and every frame gets room to land. That number is the only pacing control you have, and tuning it is the entire feel of the thing.
Linnett gave the reader the thumb in 1868. Do not take it back.