Smooth Is Not Free: Performance Architecture for GSAP and Lenis
How to keep a motion-heavy Next.js interface responsive by controlling layers, scroll work, images and animation lifecycles instead of chasing FPS after the fact.
A smooth-scrolling portfolio can feel incredibly precise on a powerful laptop and strangely heavy on a normal device.
That difference is not usually caused by one catastrophic animation. It comes from dozens of small costs accumulating at the same time: oversized images, too many compositing layers, scroll callbacks doing layout reads, filters over large surfaces, permanent will-change, and animations that never clean themselves up.
The fix is not “use fewer animations.”
The fix is to give motion a performance architecture.
Think in frame budget, not library choice
GSAP is fast. Lenis is fast. CSS transforms are fast.
None of those statements guarantee that your page is fast.
A browser still has to perform work every frame:
JavaScript → Style → Layout → Paint → CompositeGood motion architecture tries to keep recurring animation work near the end of that pipeline.
Transforms and opacity are useful because they can often be handled by compositing after the element has already been painted. But the moment an animation also forces layout, repaints a huge blurred surface or updates hundreds of nodes, the cost changes.
So I do not ask “is this a GSAP animation?”
I ask “what work will the browser repeat while this animation is active?”
Use Lenis as the scroll source, not a second animation system
When using Lenis with GSAP, the goal is to keep one coherent timing model.
A common setup is to let Lenis control the scroll interpolation and notify ScrollTrigger when the virtual scroll position changes.
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);The important architectural point is not the exact snippet. It is that scroll state has one owner.
Problems begin when separate components create their own requestAnimationFrame loops, independently read window.scrollY, then also listen to Lenis and ScrollTrigger. The page may still look fine, but it is doing duplicate work and producing timing disagreements.
One scroll source. One animation clock. Many consumers.
will-change is a temporary hint
will-change: transform is often copied into animation-heavy CSS as if it were a performance flag.
It is not.
It tells the browser that an element is likely to change, which can encourage the browser to create a separate compositing layer. That can make an upcoming transform cheaper, but every layer consumes memory and has management cost.
A page with five full-screen project panels, large images, blurred overlays and permanent will-change on every child can end up promoting far more content than necessary.
I prefer to promote only the active region.
function setActive(panel: HTMLElement, active: boolean) {
panel.style.willChange = active ? "transform" : "auto";
}You can drive this from ScrollTrigger callbacks, an active-project state or an intersection observer.
The principle is simple:
Promote near motion. Release after motion.
Avoid expensive effects on giant surfaces
A tiny blurred chip is not the same as a full-screen backdrop-filter layer.
The visual result may look similar in CSS, but the amount of screen area that must be processed is completely different.
I am especially careful with:
backdrop-filter,- large
filter: blur()values, - animated gradients,
- blend modes over large images,
- masks that cover most of the viewport,
- fixed noise textures with high opacity.
When I want atmospheric depth, I prefer several small controlled layers over one enormous dynamic effect.
For example, a project scene can use a static radial gradient plus a small animated glow near the active artwork. The user still perceives a reactive environment, but the browser does not have to continuously process the entire viewport.
Horizontal scroll scenes need measurement discipline
Pinned horizontal sections are visually powerful because vertical input is mapped into horizontal travel.
They also create an easy performance trap: repeatedly measuring panel positions inside onUpdate.
An update callback can fire many times per second. If it calls getBoundingClientRect() for every panel while other code is writing transforms in the same frame, you can force expensive synchronization between JavaScript and layout.
A better pattern is to measure stable geometry during refresh and use math during scroll.
let centers: number[] = [];
function measure() {
centers = panels.map((panel) => panel.offsetLeft + panel.offsetWidth / 2);
}
ScrollTrigger.create({
trigger: section,
onRefresh: measure,
onUpdate: (self) => {
const x = self.progress * totalTravel;
// compare x against cached centers
},
});Measure when layout changes. Calculate when scroll changes.
Images are part of the animation budget
A beautiful project archive often contains five or more large visuals inside or near the initial viewport.
If every image is loaded at full priority because the cards overlap in a hero stack, the network and decode cost can hit before the user even reaches the archive.
I treat image priority as interaction state.
Active card → eager / high priority
Next card → preload
Deep stack → lazy
Archive → lazy until approaching viewportThis is especially important when the hero contains several stacked images that are technically visible in the DOM but visually hidden behind the first card.
The browser cannot understand your art direction. You have to communicate priority deliberately.
Pointer motion should not create a tween per event
Pointer events can fire at a very high frequency.
Creating a fresh tween on every pointermove is convenient but wasteful.
For repeated motion toward changing values, GSAP's quickTo() pattern is a better fit.
const rotateX = gsap.quickTo(card, "rotationX", {
duration: 0.35,
ease: "power3.out",
});
const rotateY = gsap.quickTo(card, "rotationY", {
duration: 0.35,
ease: "power3.out",
});
function onPointerMove(event: PointerEvent) {
rotateX(getRotationX(event));
rotateY(getRotationY(event));
}One reusable tween absorbs a stream of target changes.
The same idea applies beyond GSAP: high-frequency input should update an existing system rather than allocate a new system every time.
Performance includes lifecycle cleanup
A page can become slower after navigation even if the next page is simpler.
That usually means something from the previous route survived.
Typical leaks include:
- delayed callbacks,
- RAF loops,
- global event listeners,
- ScrollTriggers,
- media-query listeners,
- stale
ResizeObservers, - transforms or
will-changeleft on persistent elements.
In a Next.js App Router project, route transitions make this especially visible because the shell can remain mounted while page content changes underneath it.
Every animated component should have an explicit destruction path.
If setup creates three observers and two timelines, cleanup should account for three observers and two timelines.
Measure on the devices that expose the truth
A desktop workstation can hide a bad architecture.
I like testing motion-heavy pages under several constraints:
- a laptop at native resolution,
- a wide but short viewport,
- mobile emulation with CPU throttling,
- reduced motion enabled,
- a cold cache,
- route navigation after several minutes of interaction.
The wide-but-short case is surprisingly useful. It exposes typography that uses vw without considering available height, pinned sections that assume a tall viewport, and artwork that becomes too large for the copy beside it.
Performance is not only “does it hit 60 FPS?”
It is also:
- does input remain immediate?
- does the layout stay stable?
- does the page recover after route changes?
- does the browser avoid loading work before it is needed?
Build the budget before the polish
My preferred order for a motion-heavy page is:
- establish layout and responsive geometry,
- add the dominant motion idea,
- profile the scene,
- add supporting motion one layer at a time,
- profile again,
- remove anything that costs more attention or computation than it returns.
This makes performance a design constraint instead of a cleanup task.
Smooth interfaces are not created by asking the browser to do everything beautifully.
They are created by deciding what the browser does not need to do.