To lazy-load GSAP in Next.js, don't import it at the top of the module. Call `import("gsap")` and `import("gsap/ScrollTrigger")` inside the `useEffect` that uses them, register the plugins after they arrive, and guard with a cancelled flag for unmounts. The bundler splits them into a separate chunk that is fetched after the component mounts. Shark Labs builds motion-heavy sites this way so no animation library sits in the bundle the first paint waits on.
This is the pattern behind every animation on the site we built for Studio Ekatva: smooth scrolling, a pinned project strip, a custom cursor and scroll reveals. None of GSAP, its plugins or Swiper is imported at the top of a module.
Why a top-level import costs more than it looks
A client component's static imports are part of its chunk. The browser has to download and run that chunk before React can hydrate the component, so a top-level import { gsap } from "gsap" makes GSAP part of the work between the HTML arriving and the page becoming interactive. Add ScrollTrigger, ScrollSmoother and a carousel library, and the heaviest thing on the page is code that only decorates it.
Animation is an enhancement over markup that is already there. It can arrive a few hundred milliseconds later without anyone noticing, as long as the page underneath looks finished without it.
The pattern
"use client";
import { useEffect } from "react";
export default function SectionReveal() {
useEffect(() => {
let triggers: { kill: () => void }[] = [];
let cancelled = false;
(async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
triggers = Array.from(
document.querySelectorAll<HTMLElement>(".sec_trigger, .s1, .textAnim"),
).map((target) =>
ScrollTrigger.create({
trigger: target,
start: "top 90%",
end: "99999 top",
toggleClass: { targets: target, className: "active" },
}),
);
ScrollTrigger.refresh();
})();
return () => {
cancelled = true;
for (const t of triggers) t.kill();
};
}, []);
return null;
}Three parts of that are not optional:
- 01`Promise.all` over the imports. Awaiting them one after another fetches the plugin only after the core has arrived. Together they download in parallel.
- 02The `cancelled` flag. The imports are asynchronous, so the component can unmount before they resolve. React Strict Mode does exactly that in development, running every effect twice. Without the flag, the discarded first run still creates its triggers, after its cleanup has already run, and they leak.
- 03`registerPlugin` after the import, not at module scope. At module scope there is nothing to register yet. Calling it on every mount is harmless: registering a plugin that is already registered does nothing.
Load only what this viewport will run
Once imports happen at runtime, you can decide at runtime which ones happen at all. The Ekatva projects section shows a pinned, scroll-driven strip on desktop and a Swiper carousel on phones. Both markups are rendered, and the section fetches only the library for the layout on screen.
const isDesktop = window.innerWidth >= DESKTOP_MIN_WIDTH;
(isDesktop ? initPinnedStrip() : initCarousel()).catch(() => {
// An animation that fails to load must not take the section with it.
});
async function initCarousel() {
// swiper/bundle ships every module pre-registered
const { default: Swiper } = await import("swiper/bundle");
if (isCancelled) return;
const carousel = new Swiper("#projects_carousel .swiper", { loop: true /* … */ });
teardown = () => carousel.destroy(true, true);
}The .catch matters as much as the split. A chunk can fail to load on a bad connection or during a deploy. Without the catch, that failure becomes an unhandled promise rejection. With it, the visitor just doesn't get the effect.
Why not next/dynamic?
next/dynamic splits *components*. It is the right tool when a whole component is heavy and can render later. It is the wrong tool here for two reasons. GSAP is a library, not a component, so there is nothing for dynamic() to render. And wrapping the animated section in dynamic(…, { ssr: false }) removes its markup from the server HTML, which takes the content away from crawlers and adds a layout shift, just to delay a script that could have been delayed on its own.
Plain import() inside the effect keeps the markup server-rendered and delays only the behaviour. The same reasoning rules out useGSAP from @gsap/react for this purpose: it is a good hook, but it imports GSAP statically, so using it puts GSAP straight back into the component's chunk.
The trap: CSS that hides content until JavaScript runs
The fix is to make the hidden state depend on the script having arrived. Add a class to <html> once the animation code has loaded, and scope every pre-animation style to it:
/* Before: invisible until JavaScript says otherwise */
.sec_trigger { opacity: 0; transition: opacity .875s ease; }
.sec_trigger.active { opacity: 1; }
/* After: only hidden once the code that will reveal it is present */
.motion-ready .sec_trigger:not(.active) { opacity: 0; }
.sec_trigger { transition: opacity .875s ease; }Add document.documentElement.classList.add("motion-ready") after the triggers are created and ScrollTrigger.refresh() has run. By then, elements already in view have active, so nothing on screen disappears. Only content below the fold gets hidden, and it is revealed when the visitor scrolls to it.
What this costs you
- Entrance animations above the fold start later. They can't start before the chunk arrives. Design the hero so its first paint is complete without motion. On the Ekatva site, the hero's first paint is a poster image rendered with
priority, and the background video is chosen after mount. - One more request. On HTTP/2 or HTTP/3 this is rarely a real cost, and it happens after the page has painted, which is the point.
- Every effect needs real cleanup. A top-level import lets you get away with sloppy teardown for longer. Asynchronous setup doesn't, which is a good thing.
The short version
Import GSAP, its plugins and any carousel library inside the effect that uses them, with Promise.all, a cancelled flag and a catch. Decide per viewport which libraries to fetch at all. Make sure the page looks finished before any of them arrive, which usually means scoping your pre-animation CSS to a class the script adds. The result is a motion-heavy site whose first paint carries none of the motion code. Fixing anchor links under ScrollSmoother applies the same pattern to smooth scrolling.