
A scroll animation is a distance budget, and the unit is the viewport height. Before any curve or duration matters, the brief has to say how much scrolling each stage of the sequence gets, because that number decides whether the effect reads as deliberate or as an obstacle between the reader and the page.
Most generated scroll sequences never state it. They pin a section, run a timeline, and the section holds for however long the timeline happens to be, which on a trackpad can mean eight seconds of scrolling to move one object.
Write the scroll map first, in viewport heights, then the settings. Both fit in a dozen lines and both are below.
Distance is the first decision#
Scroll distance behaves nothing like duration. A 600ms entrance takes 600ms for everybody. A pinned stage set to +=100% takes two flicks of a trackpad for one reader and fifteen wheel clicks for another, so the only honest way to specify it is in viewport heights and then to test it on both.
The numbers that work, gathered from sequences that do not annoy people:
- One pinned stage:
60vhto80vh. Enough for a single change to register, short enough that nobody feels held. - A four stage sequence:
240vhto320vhtotal. That is the upper end of what a reader will tolerate before they start hunting for the end. - A horizontal gallery: the track width minus one viewport, converted. Six panels of
70vweach is420vwof travel, which at a standard aspect ratio is roughly236vhof scroll. - A single reveal, no pin:
0. Trigger atstart: "top 75%"and let the element animate in place. Most scroll animation on most pages should be this, and not a pin at all.
That last bullet is the one worth arguing about. Pinning is expensive: it changes the page's height, it interacts badly with anchor links, and it removes the reader's sense of where they are in the document. A page with one pinned sequence has a signature moment. A page with four has a scroll hijack, and readers describe it in those words.
Decide the budget before writing a single tween, and the sequence will fit the page instead of the page stretching to fit the sequence.
The scroll map#
One table, one sequence, every value a model needs. This is the shape to paste.
| Stage | Pin | Distance | What changes | Scrub |
|---|---|---|---|---|
| Hero hold | yes | +=80% | camera z 5.2 to 4.1, headline mask up | 1 |
| Product turn | yes | +=70% | model rotation.y 0 to 0.62 rad | 1 |
| Detail push | yes | +=60% | camera z 4.1 to 2.6, fog density 0.085 to 0.12 | 0.8 |
| Release | no | +=30% | canvas opacity 1 to 0, copy fades up 20px | 1 |
| Feature rows | no | none | each row at start: "top 75%", 24px up, 560ms | none |
| Footer | no | none | nothing moves | none |
That sequence is 240vh of pinned scroll and then an ordinary page. The scrub value drops to 0.8 on the detail push on purpose: a shorter catch-up makes a close-up feel tighter, and a longer one makes a wide move feel heavier. Those are the only two numbers in the table that need taste, and everything else is arithmetic on the budget.
The release stage exists for a reason that is easy to miss. A pinned sequence that ends the instant its last tween finishes throws the reader straight into body copy, and the join reads as a glitch. Giving the unpin 30vh of its own, with the canvas fading and the first paragraph rising to meet it, is what turns two sections into one page.
ScrollTrigger settings that matter#
Six settings separate a sequence that survives a real browser from one that works on the machine it was written on.
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.normalizeScroll(true);
const tl = gsap.timeline({ scrollTrigger: {
trigger: "#stage", start: "top top", end: "+=240%",
pin: true, scrub: 1, anticipatePin: 1,
invalidateOnRefresh: true, fastScrollEnd: true,
}});
tl.to(camera.position, { z: 4.1, duration: 0.8, ease: "none" })
.to(model.rotation, { y: 0.62, duration: 0.7, ease: "none" })
.to(camera.position, { z: 2.6, duration: 0.6, ease: "none" });Each of the six earns its line. normalizeScroll(true) takes over scrolling on touch devices so the mobile address bar collapsing mid sequence does not resize the viewport underneath a pin. anticipatePin: 1 removes the one frame jump at the moment a pin engages. invalidateOnRefresh: true recalculates the positions on resize, without which a phone rotated to landscape shows a sequence measured for a different page. fastScrollEnd: true skips a timeline forward when somebody flicks past it.
The duration numbers inside a scrubbed timeline are proportions, not seconds. They divide the 240% of scroll between the three moves, 0.8 to 0.7 to 0.6, which is why they are relative rather than millisecond values and why an entrance brief uses different numbers entirely.
For smooth scrolling, one configuration pairs with GSAP rather than fighting it:
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
});
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);One further call is worth putting in the brief explicitly: ScrollTrigger.refresh() after web fonts and above-the-fold images have settled. Positions measured against a fallback face move by tens of pixels when the real face swaps in, and a pin that starts a third of a screen early is the most common reason a sequence looks correct locally and wrong on a cold load. Use document.fonts.ready.then(() => ScrollTrigger.refresh()) and the measurement happens once, at the right moment.
The 3D scroll case#
A three dimensional scroll sequence adds one rule to everything above: drive the camera from the timeline's progress, and never from a clock.
const state = { p: 0 };
ScrollTrigger.create({
trigger: "#stage", start: "top top", end: "+=240%",
pin: true, scrub: 1,
onUpdate: (self) => { state.p = self.progress; },
});
// inside the render loop
camera.position.z = THREE.MathUtils.damp(
camera.position.z, 5.2 - state.p * 2.6, 4, delta
);Damping towards the target rather than assigning to it is what makes a scroll driven camera feel like a camera. THREE.MathUtils.damp with a lambda of 4 settles in roughly 300ms and is frame rate independent, so the move looks the same at 60Hz and 120Hz. Assigning directly gives you a camera welded to the scroll wheel, which is the effect most generated scenes produce.

Two more values belong in the brief. Keep the scene's triangle count under 150,000 so the scrub stays at sixty frames while the page is also laying out text, and clamp the device pixel ratio to 2, because a scrubbed sequence on an uncapped high density screen is the most reliable way to make a phone warm.
The render loop should also stop when nobody is looking at it. An IntersectionObserver on the canvas that cancels the frame request once the stage leaves the viewport costs four lines and removes the whole cost of the scene from the rest of the page, which matters most on the machines that need it most.
The verdict on scroll briefs#
Distance first, settings second, tweens last. A brief that names its viewport heights produces a sequence that fits the page; a brief that says smooth scroll animations produces a pin of unknown length holding a reader hostage, which is the difference specification makes everywhere.
The short version, to paste and edit:
- Total pinned distance in
vh, split per stage. scrubas a number between0.6and1.2, nevertrue.ease: "none"on every tween inside the scrub.anticipatePin: 1,invalidateOnRefresh: true,normalizeScroll(true).- One pinned sequence per page. Everything else triggers at
top 75%. - A reduced motion branch that jumps to the end state.
The sixth line is the one that gets skipped, and it is two lines of code. With prefers-reduced-motion: reduce set, kill the pin, set the camera and the copy to their final positions, and let the page scroll normally. The reader still gets the whole content; they just do not get moved through it.
Thanor is a subscription library of briefs written exactly like that: full art direction per design, in exact values, covering scroll sequences, three dimensional scenes, animated backgrounds and page sections. $89 for three months, $189 a year, $299 once, and free accounts open the designs marked free.
Browse the Thanor library and read one sequence's distances before you guess your own. If the scene inside the pin is the part you want, the camera and material values are here, and one free design shows the format end to end.
Questions this raises
How long should a scroll animation be?
Measure it in viewport heights, not seconds. One pinned stage wants 60vh to 80vh of scroll, so a four stage sequence needs about 240vh to 320vh. Past roughly 400vh a reader starts scrolling faster to find the end.
Should scrub be true or a number?
A number, almost always. scrub: true ties the timeline to the scroll position exactly, which is twitchy on a trackpad. scrub: 1 gives it a one second catch-up and reads as a camera move rather than a jitter.
What is the right easing for a scrubbed timeline?
None at all. Write ease "none" on every tween inside a scrubbed timeline, because the reader's own scroll velocity is already the curve. A second curve on top makes the animation feel like it is lagging.
Do scroll animations work on mobile?
Yes, with two additions. ScrollTrigger.normalizeScroll(true) removes the address bar resize jitter, and every pinned distance wants checking at 100svh rather than 100vh so the sequence does not overrun.
Part of
Build it with AI
Three dimensional scenes, scroll driven motion and animated pages, built by handing a model a specification instead of an adjective. What to write, and what comes back.
