Most scroll animation is built backwards. Something enters the viewport, an animation fires, and the page moves on. That is a trigger, not a timeline — and the difference shows up the first time someone scrolls back up.
Playhead, not trigger
The shift is to treat scroll position as a playhead. A section owns a timeline; the user's scroll scrubs it forwards and backwards. Nothing "fires". Everything is a function of progress.
const timeline = gsap.timeline({
scrollTrigger: {
trigger: section,
start: 'top top',
end: '+=150%',
scrub: true,
pin: true
}
});
timeline
.from(headline, { yPercent: 110 })
.to(plate, { scale: 0.92 }, '<')
.from(caption, { opacity: 0 }, '-=0.2');Once the scene is a timeline, reversibility is free, and the question you ask while designing changes from "what happens when" to "what does 40% look like".
Structure the page around scenes
A scene is a pinned region with a fixed duration measured in scroll distance. Thinking this way has practical consequences:
- Duration is layout.
end: '+=150%'adds a screen and a half of scroll. Budget it like you would budget height. - Pin sparingly. Every pinned scene is a moment where the page stops responding to the user the way they expect. Earn it.
- One easing for scrubbed motion.
ease: 'none'on scrubbed tweens; let the smooth-scroll layer provide the feel.
Let CSS take the cheap half
Not everything needs JavaScript any more. A reading-progress bar is a scroll-driven animation in three declarations — it runs on the compositor and costs nothing on the main thread:
.progress {
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root);
}Save the timeline library for the scenes that genuinely need orchestration, and let the platform handle the rest.