Skip to content

Writing

How to lazy-load GSAP in Next.js, and keep it out of first paint

A top-level `import gsap` puts the whole animation stack in the bundle your page waits on. Importing it inside the effect that needs it moves it after first paint, and only costs you a few lines.

By Yash Mittal4 min read

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;
}
The Studio Ekatva scroll reveal. Nothing from GSAP is imported at the top of the file.

Three parts of that are not optional:

  1. 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.
  2. 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.
  3. 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);
}
A desktop never downloads Swiper, and on a phone this section asks for nothing but Swiper.

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; }
Without `.motion-ready`, every element renders in its final state. The animation becomes something the page gains, not something it depends on.

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.

This is what we do. How we build fast, motion-heavy websites.

How we build fast, motion-heavy websites

FAQ

Related questions

Does dynamic import() work with the Next.js App Router and Turbopack?

Yes. import() is standard JavaScript, and both Turbopack and webpack turn each dynamically imported module into a separate chunk. The only requirement is that the code runs on the client, which it does inside useEffect in a "use client" component. You don't need any Next.js configuration.

Won't the animation flash or jump when GSAP arrives late?

Only if the page's starting state depends on the animation. If pre-animation styles are scoped to a class the script adds, the page renders in its final state first. The animation then applies only to content the visitor hasn't reached yet, so nothing visibly jumps. Design the above-the-fold area so it doesn't need an entrance animation to look right.

Is this worth it for a site with one small animation?

For a single tween, CSS transitions or the Web Animations API usually beat loading GSAP at all. This pattern pays off when the page needs ScrollTrigger pinning, scrubbing, smooth scrolling or a carousel, which is when GSAP and its plugins become the heaviest code on the page.

Keep reading

More on this

Article

Fixing anchor links under GSAP ScrollSmoother in Next.js

ScrollSmoother translates your page instead of scrolling it, so the browser's own jump to #services lands in the wrong place. One delegated handler fixes it without touching a single link.

Next step

Want this applied to your situation?

Articles generalise. A 45-minute call doesn't — tell us what you're actually dealing with and we'll be specific.

Start a projectRead more articles