When scroll animation feels wrong, the problem is almost never the speed or the easing. It is that the element's position has stopped being a continuous function of the scroll.
Scroll-linked animation has an unusual property: the user controls time. They can go fast, go slow, stop halfway, reverse. That exposes faults which a normal animation, running on its own fixed duration, never reveals.
Building this site we hit several of them. What follows is each one, what it looked like, and what actually fixed it. Most cost the same thing to accept: scroll is not a timeline.
The bounce that is not a bounce
The symptom is a subtle lurch partway through, as though the element hit something. The cause is usually a chain of tweens on the same property.
// Three chained tweens on the same y
tl.to(el, { y: 300, ease: 'power2.inOut' })
.to(el, { y: 600, ease: 'power2.inOut' })
.to(el, { y: 900, ease: 'power2.inOut' });It looks continuous because the positions line up: 300 ends where 600 begins. But `power2.inOut` arrives at zero velocity and the next tween accelerates from zero again. Position is continuous; velocity is not. The eye reads that discontinuity as a knock.
This is worth stating precisely, because it explains a whole family of problems. Smoothness is not a property of position. It is a property of the derivatives of position. A path can be perfectly continuous and still look broken if its first derivative jumps, and if the second derivative jumps you get something that reads as a soft thud rather than a hit.
The fix is not a different curve, it is a different model. Instead of chaining segments, write position as a function of progress and evaluate it every frame.
const apply = (progress) => {
const y = curve(progress) * TRAVEL;
setY(y); // gsap.quickSetter
};
ScrollTrigger.create({
trigger: section,
scrub: 1.3,
onUpdate: (self) => apply(self.progress),
});With one function, velocity is continuous by construction. There are no seams because there are no segments. It also makes the whole thing debuggable in a way a timeline is not: you can sample the function at a thousand points and look at the result without touching the DOM.

Why does my scroll animation feel jerky?
Almost always because it is built as a chain of tweens on the same property. Ease-in-out curves finish at zero velocity and the next tween accelerates from zero again: position stays continuous but velocity does not, and the eye reads that jump as a lurch. The fix is to write position as a single continuous function of scroll progress and evaluate it every frame, rather than chaining segments. If it still feels wrong after that, the next suspects are animating a property that triggers layout, and a scrub value low enough that the animation tracks input noise.
Easing curves lie about where the movement is
A related trap, and one that cost us a working afternoon on a blur transition. The element was set to fade and blur over 900 milliseconds with an expo.out curve, and it looked instant with a long tail of nothing.
That is exactly what expo.out does. Roughly 80% of its travel happens in the first 20% of its duration. On a short interface response that reads as snappy, which is why it is a good default there. On a slow, deliberate transition it reads as a glitch followed by a wait.
The lesson generalises: duration and curve are not independent settings. A long duration with an aggressive out-curve is not a slow animation, it is a fast animation with padding. When something should feel unhurried, the curve has to distribute its travel, which usually means an in-out curve or a gentle custom bezier.
A GSAP setter that does nothing
This one cost us an afternoon too. We wanted to scale an element every frame and reached for the obvious thing:
const setScale = gsap.quickSetter(el, 'scale'); // does nothingThe element moved and rotated but never scaled. No console error, no warning. Reading the inline style showed why:
translate: none; rotate: none; scale: none;
transform: translate3d(...) rotate(...) rotateX(...);GSAP pins the individual CSS properties `translate`, `rotate` and `scale` to none so they cannot compete with its own `transform`. A quickSetter on `"scale"` writes exactly that neutralised property. Opacity worked throughout because it is an ordinary property GSAP does not pin.
`scaleX` and `scaleY` are real transform components and land in the same cache as `x`, `y` and `rotation`. It is the difference between working and failing silently, and it is not documented anywhere obvious.
The general habit this taught us is worth more than the specific fix. When a property refuses to animate and nothing errors, read the computed style rather than the code. The browser will tell you what actually got written, and it is frequently not what you think you wrote.
Curves with a corner where you do not want one
We wanted screens that arrive from the right and also leave to the right. It sounds trivial until you write it: the offset has to stay positive on both sides of the settle.
`sin θ` will not do, because it is odd: enter positive and you leave negative. `|sin θ|` satisfies the requirement but has a corner exactly at zero, and that corner is an abrupt reversal of lateral velocity. You can see it.

const CORNER = 0.11;
const softAbs = (v) => (v * v) / Math.sqrt(v * v + CORNER * CORNER);That function tracks `|v|` once the value clears zero and goes quadratic near it. Velocity eases through zero instead of bouncing off it. We checked by sampling the path over two hundred thousand points: worst lateral acceleration lands at about 3% of peak velocity.
The constant is the whole design. Too small and the corner comes back, because the quadratic region shrinks below the width of a frame. Too large and the element visibly hesitates at the centre, since it now spends real time near zero. 0.11 was found by looking, not by deriving, and that is an honest description of how most of these constants get set.
How do you make an element enter and leave on the same side without a jolt?
You need a function that stays positive on both sides of the settle, and any such curve that touches zero has a corner there. The fix is a smoothed absolute value: (v·v) / sqrt(v·v + k·k), with a small k. It tracks |v| away from zero and turns quadratic near it, so velocity crosses zero continuously instead of reversing in one frame. Tune k by eye: too small and the corner returns, too large and the element hesitates in the middle.
Scrub, and why smooth scroll changes the brief
Scrub is the delay between the scroll position and the animation catching up to it. Set to true, the animation is locked to the scroll and follows every twitch of a trackpad. Set to a number, it eases toward the target over that many seconds.
Locked to the scroll sounds correct and usually feels worst. Real scroll input is noisy, and an animation that reproduces the noise faithfully looks nervous. A scrub of somewhere between 1 and 1.5 acts as a low-pass filter on the user's hand.
Smooth scrolling then compounds this, and it is where a lot of sites go wrong. A smooth-scroll library is already interpolating the scroll position toward its target. Adding a large scrub on top means two filters in series, and the result is an animation that feels like it is being dragged through syrup, always arriving after the reader has stopped caring.
The two settings have to be chosen together. Our smooth scroll runs at a fairly low interpolation factor, which is already gentle, so the scrub on top is 1.3 rather than the 2 or 3 that feels right when tested against native scrolling. If you change one, retest the other.
One implementation detail that removes a whole class of stutter: drive the smooth scroll from the same ticker as the animation library, rather than letting each run its own frame loop. Two loops means two chances per frame to disagree about what time it is.
// One clock for both, so they cannot drift apart.
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);Only two properties are free, and everything else is a bill
Animating on scroll means recalculating on every frame the user produces, which on a 120Hz display is a budget of about eight milliseconds for everything, including whatever else the page is doing.
Transform and opacity are handled by the compositor and cost almost nothing per frame. Everything else pulls work back into the main thread. Width, height, top, margin and padding force layout, which means the browser recalculates the geometry of the page and then repaints it. Doing that sixty times a second is how a fast machine ends up dropping frames.
`filter: blur()` deserves a separate warning because it is not a layout property and still ruins scroll performance. It is a per-pixel operation over the whole element, recomputed at every value, and on a large element it is expensive enough to be visible on a laptop and unusable on a phone.
Which matters more than it sounds, because the gap between where these things get built and where they get used is measurable.
Two habits keep this honest. Write values through a setter that skips GSAP's per-call overhead when you are updating every frame, and never read layout inside the update. A single `getBoundingClientRect` in a scroll handler forces the browser to flush pending changes before it can answer, which is the classic way to convert a smooth animation into a slideshow.
- Cache anything measured, and recompute it on resize rather than on scroll.
- Set `will-change` when an animation starts and remove it when it ends, since leaving it on permanently costs memory on every element that has it.
- Prefer one element moving a long way over many elements moving a little, because the cost scales with the number of layers the compositor has to manage.
Reduced motion is a setting, not a checkbox
A meaningful number of people have reduced motion enabled at the operating system level, some because large moving areas make them ill. Scroll-linked animation is precisely the category that causes it.
The common implementation is wrong in a specific way. Disabling the animation is taken to mean disabling the code that runs it, and since that code is also what makes the element visible, the content never appears.
// Wrong: the section stays at opacity 0 forever.
if (prefersReducedMotion()) return;
// Right: skip the journey, keep the destination.
if (prefersReducedMotion()) {
gsap.set(elements, { opacity: 1, y: 0, clearProps: 'transform' });
return;
}The rule that keeps this straight: reduced motion removes the transition, never the outcome. Anything that was going to become visible becomes visible immediately.
How to check that it actually works
The verification mistake that cost us most was measuring positions with `getBoundingClientRect` and calling it done. Rects tell you where a box is, not whether something opaque is painted over it. Twice we reported a section as working while it was blank.
`document.elementFromPoint` answers the right question, which is what is visible at that pixel. It also pays to walk ancestor opacity: if a container sits at zero, its children still report an opacity of 1 in their own computed style, so a naive check on the element itself passes while nothing is on screen.
// Is this element the thing a user would actually click?
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
console.log(el.contains(hit) || hit === el);
// And is any ancestor hiding it?
let node = el, alpha = 1;
while (node) { alpha *= +getComputedStyle(node).opacity; node = node.parentElement; }One more caution learned the hard way. Sampling positions while scripting the scroll gives false readings if the samples come faster than the smooth scrolling can settle. At a low interpolation factor the position keeps moving for a second or more after the last input, so a loop that scrolls and measures immediately is measuring a transient.
Is scroll animation bad for performance and SEO?
It is bad for neither when it is built correctly, and bad for both when it is not. On performance, animating only transform and opacity keeps the work on the compositor and off the main thread; animating layout properties or blur does the opposite. On SEO, the content has to exist in the HTML and be visible without JavaScript having run, which means reveal animations should start from a visible state and be hidden by script rather than hidden in the markup. A page whose text only appears after a scroll listener fires is a page some crawlers will record as empty.
None of these fixes changes the design. They change whether the design feels made or bought, which on a website is a difference visitors notice without being able to name it.


