Build With Umar Logo
← Back to Insights
2026-05-18Tech Guide

GSAP & Framer Motion in Next.js 15

GSAP & Framer Motion in Next.js 15 | Build With Umar

In 2026, the tension at the centre of every senior frontend architectural decision is no longer whether to animate it is whether your animation pipeline can survive production scrutiny without detonating your Core Web Vitals, your Interaction to Next Paint budget, or your server-rendered HTML.

The demand is unambiguous on both sides. Enterprise stakeholders, product managers, and conversion-rate specialists know that premium motion design is no longer a decorative indulgence it is a measurable variable in user trust, session duration, and checkout completion. Research consistently maps smooth, purposeful motion to double-digit improvements in engagement metrics. Meanwhile, the performance engineering discipline has never been more exacting. Google's field metrics are ranking signals. INP replaced FID. Lighthouse 12 is embedded in CI pipelines. Your animation code is audited every time a page loads.

This is the hydration ceiling: the invisible performance wall that teams hit when they wire sophisticated animation engines GSAP's timeline orchestration, Framer Motion's spring physics directly into components that the Next.js 15 App Router has already server-rendered and streamed to the browser as static HTML. The animation library initialises before hydration completes. React discovers mismatches between the server tree and the client DOM. It discards the server HTML entirely and re-renders from scratch. Your LCP score craters. Your CLS metric fires. Your INP budget carefully conserved everywhere else is obliterated by a single useEffect that ran 80 milliseconds too early.

This guide is a complete architectural treatment of how to break through that ceiling. Every pattern here is production-tested, TypeScript-strict, and written for the Next.js 15 App Router. We cover the root mechanisms, the isomorphic isolation strategies, the dynamic code splitting patterns, and the complete production implementations for both GSAP and Framer Motion including the business and conversion ROI case that justifies the engineering investment.


The Root Problem: What Happens When Animation Engines Meet Hydration

To understand why the hydration ceiling exists, you need a precise mental model of what Next.js 15 App Router does before your animation code executes a single line.

When a user requests a page, the App Router executes your React Server Components on the server. It renders your component tree to HTML fully formed, fully populated with content and streams that HTML to the browser. The browser parses and paints the HTML. The user sees content. This is your LCP window. It is fast precisely because no client JavaScript has executed yet.

Then the hydration phase begins. React downloads the client bundle, bootstraps, and walks the server-rendered DOM tree, attaching event listeners and synchronising client-side state to match the server-rendered structure. During this walk, React is performing a strict node-by-node comparison. Every DOM node it encounters must exactly match what it expects based on the component tree. If anything diverges an extra element, a missing attribute, a style that the server did not render React throws a hydration error and, in production, silently discards the server HTML and re-renders the entire tree from the client. Your LCP paint is wasted. The user experiences a flash of unstyled or repositioned content.

Now introduce a GSAP ScrollTrigger or a Framer Motion motion.div that reads window.innerWidth on mount, or calls gsap.set() to apply initial transform states before the animation plays. Both of these operations modify the DOM. They execute during or immediately after mount which, in concurrent React, can race with the hydration reconciliation pass. The result is a DOM that no longer matches the server tree. React's reconciler flags the divergence. Your carefully optimised server render is discarded.

The problem compounds further with useLayoutEffect. This hook fires synchronously after every DOM mutation, before the browser has a chance to paint. In a server environment during the RSC render pass useLayoutEffect does not exist. React logs a warning: "Warning: useLayoutEffect does nothing on the server". Developers who ignore this warning and use useLayoutEffect to initialise GSAP are introducing a timing dependency that can produce invisible failures: animations that initialise against a DOM that React is still reconciling, producing layout shifts that register as CLS violations in Chrome's field data collection.

The hydration ceiling is not a single bug. It is a class of architectural errors produced by treating animation initialisation as a client-only concern without properly isolating it from the server render lifecycle and the hydration reconciliation pass.


Technical Strategy 1: Isomorphic Layout Isolation

The first structural fix addresses the useLayoutEffect SSR incompatibility directly. The solution is an isomorphic hook a hook that resolves to useLayoutEffect on the client and useEffect on the server, ensuring that GSAP initialisation calls are never invoked during the server render pass while still maintaining synchronous DOM-read timing on the client.

The Isomorphic Hook Pattern

// lib/hooks/useIsomorphicLayoutEffect.ts
import { useEffect, useLayoutEffect } from "react";

/**
 * Resolves to useLayoutEffect on the client and useEffect on the server.
 * Eliminates the SSR console warning for animation libraries that require
 * synchronous DOM access before paint (GSAP, anime.js, custom canvas setups).
 *
 * Use this as a drop-in replacement for useLayoutEffect in any component
 * that may be rendered server-side within the Next.js 15 App Router.
 */
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

export default useIsomorphicLayoutEffect;

This is not a workaround it is the canonical pattern recommended by the React team for hooks that require synchronous DOM access. The typeof window !== "undefined" check is evaluated once per module load on the client and resolves to the correct hook reference. There is zero runtime overhead after the initial evaluation.

Applying Isomorphic Isolation to GSAP Initialisation

// components/animations/HeroReveal.tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
import useIsomorphicLayoutEffect from "@/lib/hooks/useIsomorphicLayoutEffect";

gsap.registerPlugin(useGSAP);

interface HeroRevealProps {
  children: React.ReactNode;
  delay?: number;
  stagger?: number;
}

export default function HeroReveal({
  children,
  delay = 0,
  stagger = 0.08,
}: HeroRevealProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  /**
   * useIsomorphicLayoutEffect ensures this block never executes server-side.
   * On the client, it fires synchronously after DOM mutation before paint —
   * which is essential for setting GSAP initial states without a flash.
   *
   * Without this pattern, GSAP's gsap.set() calls can execute against a DOM
   * that React's reconciler has not yet finished hydrating, producing CLS.
   */
  useIsomorphicLayoutEffect(() => {
    if (!containerRef.current) return;

    // Set initial states synchronously before paint to prevent CLS
    gsap.set(containerRef.current.querySelectorAll("[data-reveal]"), {
      opacity: 0,
      y: 40,
      willChange: "transform, opacity",
    });
  }, []);

  useGSAP(
    () => {
      if (!containerRef.current) return;

      const elements = containerRef.current.querySelectorAll("[data-reveal]");

      if (!elements.length) return;

      gsap.to(elements, {
        opacity: 1,
        y: 0,
        duration: 0.7,
        ease: "power3.out",
        delay,
        stagger,
        clearProps: "willChange", // Release GPU layer after animation
        onComplete: () => {
          // Remove will-change to avoid unnecessary compositing layers
          (elements as NodeListOf<HTMLElement>).forEach((el) => {
            el.style.willChange = "auto";
          });
        },
      });
    },
    { scope: containerRef, dependencies: [delay, stagger] }
  );

  return (
    <div ref={containerRef} style={{ overflow: "hidden" }}>
      {children}
    </div>
  );
}

The critical architectural detail here is the separation of concerns between the two hooks. useIsomorphicLayoutEffect owns the synchronous initial state setting the DOM operation that must complete before the browser paints to prevent CLS. useGSAP owns the animation definition itself. This sequence guarantees that elements are invisible before paint (no FOUC), become visible through the animation (no jump), and release their GPU compositing layer after the animation completes (no memory overhead).

The clearProps: "willChange" call at animation completion is not cosmetic. will-change: transform promotes an element to its own compositing layer in the browser's rendering pipeline. That layer consumes GPU memory. Leaving it active after an animation completes across dozens of elements on a page can produce memory pressure that degrades scroll performance manifesting as INP spikes on subsequent interactions.


Technical Strategy 2: Non-Blocking Code Splitting With next/dynamic

Isomorphic layout isolation solves the hydration timing problem. But it does not solve the bundle weight problem. GSAP's core library weighs approximately 67kb minified. With ScrollTrigger, ScrollSmoother, and MorphSVG, you can easily exceed 150kb of animation engine code code that must parse and execute on the main thread before any GSAP-powered element can animate.

Framer Motion's situation is more nuanced but equally significant. The full framer-motion package including all motion components, layout animations, gesture recognition, and the animation engine exceeds 90kb minified. Even with tree-shaking, a typical Framer Motion integration contributes 40–60kb to the initial page bundle.

These are not trivial costs. Main thread JavaScript parsing is the primary driver of poor INP scores on mid-range hardware. A 100kb JavaScript payload takes approximately 300–500 milliseconds to parse and compile on a Snapdragon 695 the current benchmark device for realistic mobile performance testing. Every millisecond of main thread occupation during that window is a millisecond of blocked user interaction. If a user taps a button during animation library initialisation, the interaction response is deferred. INP records the delay. Google's field data collection flags your page.

The solution is dynamic import with SSR disabled deferring the animation bundle entirely until after hydration completes, and after the browser has finished processing all critical-path resources.

Dynamically Importing GSAP-Powered Components

// app/page.tsx (React Server Component App Router)
import dynamic from "next/dynamic";
import { Suspense } from "react";

/**
 * AnimatedHero is imported dynamically with ssr: false.
 * This means:
 * 1. The component's JavaScript is NOT included in the initial page bundle.
 * 2. The component is NOT rendered on the server no SSR HTML contribution.
 * 3. The component's chunk is fetched as a separate network request,
 *    after the critical rendering path has completed.
 * 4. GSAP and all its plugins are bundled ONLY into this deferred chunk.
 *
 * The static server-rendered fallback (<HeroSkeleton />) holds layout
 * space during the async load, preventing CLS from layout reflow.
 */
const AnimatedHero = dynamic(
  () => import("@/components/animations/AnimatedHero"),
  {
    ssr: false,
    loading: () => <HeroSkeleton />,
  }
);

const AnimatedFeatureGrid = dynamic(
  () => import("@/components/animations/AnimatedFeatureGrid"),
  {
    ssr: false,
    loading: () => <FeatureGridSkeleton />,
  }
);

// Skeleton components are pure RSC zero JavaScript, zero bundle cost.
// They render server-side and hold layout space until animated components load.
function HeroSkeleton() {
  return (
    <section
      className="hero-section"
      aria-hidden="true"
      style={{
        minHeight: "100svh",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <div className="hero-skeleton-content" />
    </section>
  );
}

function FeatureGridSkeleton() {
  return (
    <section
      className="feature-grid-section"
      aria-hidden="true"
      style={{ minHeight: "600px" }}
    >
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="feature-card-skeleton" />
      ))}
    </section>
  );
}

export default function HomePage() {
  return (
    <main>
      {/*
       * Suspense boundary ensures the RSC stream is not blocked
       * waiting for dynamically imported client components.
       * The server streams the skeleton immediately; the client
       * hydrates and replaces it when the animation bundle loads.
       */}
      <Suspense fallback={<HeroSkeleton />}>
        <AnimatedHero />
      </Suspense>

      <Suspense fallback={<FeatureGridSkeleton />}>
        <AnimatedFeatureGrid />
      </Suspense>
    </main>
  );
}

This pattern produces a measurable impact on two Core Web Vitals simultaneously. LCP improves because the initial HTML payload is smaller no animation bundle blocking the critical path and the server-rendered skeleton content paints immediately without waiting for JavaScript. CLS is contained because the skeleton components maintain identical layout dimensions to the animated components they replace, preventing reflow when the dynamic component swaps in.

Configuring Webpack Bundle Analysis for Animation Chunks

Understanding exactly what your dynamic import chunks contain and how large they are requires integrating @next/bundle-analyzer into your build pipeline:

// next.config.ts
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
  openAnalyzer: false,
  analyzerMode: "static",
  reportFilename: "bundle-report.html",
});

const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: [
      "framer-motion",
      "gsap",
      "@gsap/react",
    ],
  },
  webpack(config, { isServer }) {
    if (!isServer) {
      /**
       * Ensure GSAP plugins are bundled only into client chunks.
       * This prevents any attempt to import browser-specific GSAP
       * plugins (ScrollTrigger, Draggable) in the server bundle.
       */
      config.resolve.alias = {
        ...config.resolve.alias,
        "gsap/ScrollTrigger": isServer
          ? false
          : require.resolve("gsap/ScrollTrigger"),
        "gsap/ScrollSmoother": isServer
          ? false
          : require.resolve("gsap/ScrollSmoother"),
      };
    }
    return config;
  },
};

export default withBundleAnalyzer(nextConfig);

Run ANALYZE=true next build to generate an interactive treemap of every module in your production bundle. Animation library chunks should appear as separate, deferred nodes entirely disconnected from your main page bundle. If GSAP or Framer Motion appear in your main chunk, your dynamic imports are not isolating correctly, and you are paying the full bundle cost on every page load regardless of whether animations are visible.


Technical Strategy 3: GSAP Context Scoping and Lifecycle Cleanup

The third class of production failures with GSAP in React involves memory management and animation context lifecycle. This is particularly acute in the Next.js 15 App Router, which uses React 18's concurrent rendering model meaning components can be rendered, unmounted, and re-rendered multiple times in rapid succession as the router handles navigation.

GSAP animations that are not properly scoped and cleaned up produce two failure modes. First, they accumulate in GSAP's internal timeline registry each navigation away and back to a page creates a new set of animations stacked on top of undisposed previous animations, producing unpredictable motion behaviour and memory leaks that degrade performance over a user session. Second, they can attempt to animate DOM nodes that React has already unmounted producing JavaScript errors and, in some cases, silent DOM corruption.

The useGSAP hook from @gsap/react is the correct production solution. It wraps GSAP's context() API, automatically scopes all animations created within it to a container ref, and disposes the context killing all animations, removing all ScrollTrigger instances, and freeing all memory when the component unmounts.

Complete GSAP ScrollTrigger Implementation With Context Cleanup

// components/animations/AnimatedFeatureGrid.tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";

// Register plugins once at module level not inside component render
gsap.registerPlugin(ScrollTrigger, useGSAP);

interface FeatureCard {
  id: string;
  title: string;
  description: string;
  metric: string;
  metricLabel: string;
}

const features: FeatureCard[] = [
  {
    id: "performance",
    title: "Core Web Vitals Optimisation",
    description:
      "Architecture-level performance engineering that moves the needle on LCP, INP, and CLS in Google's field data collection.",
    metric: "98",
    metricLabel: "Lighthouse Score",
  },
  {
    id: "animation",
    title: "Non-Blocking Motion Systems",
    description:
      "GSAP and Framer Motion pipelines built to run off the main thread without contributing to INP latency budgets.",
    metric: "<50ms",
    metricLabel: "Interaction Delay",
  },
  {
    id: "architecture",
    title: "RSC-First Architecture",
    description:
      "Server Component boundaries that eliminate unnecessary client JavaScript while preserving rich interactive surfaces.",
    metric: "60%",
    metricLabel: "Bundle Reduction",
  },
  {
    id: "seo",
    title: "Structural SEO Engineering",
    description:
      "Technical SEO pipelines that align crawl efficiency, metadata generation, and rendering strategy for maximum indexation fidelity.",
    metric: "3×",
    metricLabel: "Organic Traffic Growth",
  },
  {
    id: "design",
    title: "Premium Design Systems",
    description:
      "Component libraries and design tokens engineered for visual consistency at scale across all product surfaces.",
    metric: "100%",
    metricLabel: "Design Fidelity",
  },
  {
    id: "integration",
    title: "Full-Stack Integration",
    description:
      "End-to-end application architecture from database schema to UI component no handoff gaps, no integration overhead.",
    metric: "1",
    metricLabel: "Integrated Team",
  },
];

export default function AnimatedFeatureGrid() {
  const containerRef = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      /**
       * All animations defined within this callback are automatically:
       * 1. Scoped to containerRef querySelectorAll operates only within
       *    the container, preventing targeting of external DOM elements.
       * 2. Registered in a GSAP context the context is disposed when
       *    the component unmounts, killing all animations and ScrollTriggers.
       * 3. Tracked for cleanup no manual cleanup in useEffect return required.
       */

      const cards = gsap.utils.toArray<HTMLElement>(
        containerRef.current!.querySelectorAll("[data-feature-card]")
      );

      const metrics = gsap.utils.toArray<HTMLElement>(
        containerRef.current!.querySelectorAll("[data-metric-value]")
      );

      // Set initial states before ScrollTrigger pins activate
      gsap.set(cards, {
        opacity: 0,
        y: 60,
        scale: 0.96,
        willChange: "transform, opacity",
      });

      gsap.set(metrics, {
        opacity: 0,
        y: 20,
      });

      // Staggered card reveal on scroll entry
      ScrollTrigger.batch(cards, {
        onEnter: (elements) => {
          gsap.to(elements, {
            opacity: 1,
            y: 0,
            scale: 1,
            duration: 0.65,
            ease: "power2.out",
            stagger: 0.1,
            clearProps: "willChange,scale",
          });
        },
        onEnterBack: (elements) => {
          gsap.to(elements, {
            opacity: 1,
            y: 0,
            scale: 1,
            duration: 0.45,
            ease: "power2.out",
            stagger: 0.06,
          });
        },
        onLeave: (elements) => {
          gsap.to(elements, {
            opacity: 0,
            y: -30,
            duration: 0.35,
            ease: "power2.in",
          });
        },
        start: "top 85%",
        end: "bottom 15%",
      });

      // Counter animation for metric values fires once on first scroll entry
      cards.forEach((card, index) => {
        const metricEl = metrics[index];
        const rawValue = metricEl?.getAttribute("data-metric-raw");

        if (!metricEl || !rawValue || isNaN(Number(rawValue))) return;

        const targetValue = Number(rawValue);
        const counterObj = { value: 0 };

        ScrollTrigger.create({
          trigger: card,
          start: "top 80%",
          once: true, // Counter only runs once no repeat on scroll
          onEnter: () => {
            gsap.to(metricEl, { opacity: 1, y: 0, duration: 0.4 });
            gsap.to(counterObj, {
              value: targetValue,
              duration: 1.8,
              ease: "power1.inOut",
              onUpdate: () => {
                metricEl.textContent = Math.round(counterObj.value).toString();
              },
            });
          },
        });
      });

      /**
       * Refresh ScrollTrigger after all animations are defined.
       * This is critical in App Router environments where route transitions
       * can alter document height before ScrollTrigger has recalculated positions.
       */
      ScrollTrigger.refresh();
    },
    {
      scope: containerRef,
      dependencies: [], // No reactive dependencies animations are static
    }
  );

  return (
    <section
      ref={containerRef}
      className="feature-grid-section"
      aria-label="Platform capabilities"
    >
      <div className="feature-grid-inner">
        <header className="section-header">
          <h2 data-reveal>Engineering Capabilities</h2>
          <p data-reveal>
            Production-grade technical services built for enterprise product
            teams who understand that performance and visual quality are not a
            trade-off.
          </p>
        </header>

        <div className="feature-grid">
          {features.map((feature) => (
            <article
              key={feature.id}
              data-feature-card
              className="feature-card"
              aria-label={feature.title}
            >
              <div className="feature-card-metric">
                <span
                  data-metric-value
                  data-metric-raw={
                    isNaN(Number(feature.metric))
                      ? "0"
                      : feature.metric
                  }
                  className="metric-value"
                  aria-label={`${feature.metric} ${feature.metricLabel}`}
                >
                  {feature.metric}
                </span>
                <span className="metric-label">{feature.metricLabel}</span>
              </div>
              <h3 className="feature-card-title">{feature.title}</h3>
              <p className="feature-card-description">{feature.description}</p>
            </article>
          ))}
        </div>
      </div>
    </section>
  );
}

The ScrollTrigger.batch() API is preferable to individual ScrollTrigger instances when animating multiple elements with shared trigger logic. It batches intersection calculations into a single pass per scroll tick rather than running N individual IntersectionObserver callbacks significantly reducing main thread load on pages with dense animated element counts. This directly protects your INP budget during scroll interactions.


Technical Strategy 4: Framer Motion LazyMotion and Feature Bundle Reduction

Framer Motion's architecture is modular, but its default import pattern loads the entire feature set including layout animations, drag gesture recognition, AnimatePresence, and the full motion value system regardless of which features your components actually use. The LazyMotion API corrects this.

Full LazyMotion Implementation

// components/providers/MotionProvider.tsx
"use client";

import { LazyMotion, domAnimation } from "framer-motion";

/**
 * LazyMotion with domAnimation feature bundle loads only the animations
 * needed for DOM-based transitions: opacity, transforms, colour, path drawing.
 * It excludes layout animations, drag, and pan gestures reducing
 * the Framer Motion bundle contribution from ~90kb to ~18kb.
 *
 * If layout animations are needed, import domMax instead:
 * import { domMax } from "framer-motion";
 * Note: domMax adds ~35kb for layout projection calculations.
 *
 * For async loading (further reducing initial bundle):
 * const loadFeatures = () => import("framer-motion").then(m => m.domAnimation);
 * <LazyMotion features={loadFeatures}>
 */
interface MotionProviderProps {
  children: React.ReactNode;
}

export function MotionProvider({ children }: MotionProviderProps) {
  return (
    <LazyMotion features={domAnimation} strict>
      {/*
       * strict mode enforces that only `m.*` components (not `motion.*`)
       * are used within this boundary. motion.* imports the full bundle
       * regardless of LazyMotion using them defeats the optimization.
       * strict: true will throw a dev-mode error if motion.* is detected.
       */}
      {children}
    </LazyMotion>
  );
}
// app/layout.tsx
import { MotionProvider } from "@/components/providers/MotionProvider";
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: {
    template: "%s | Build With Umar",
    default: "Build With Umar Premium Full-Stack Engineering",
  },
  description:
    "Full-stack engineering, Core Web Vitals optimisation, and premium UI design for enterprise product teams.",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        {/*
         * MotionProvider wraps the entire application at layout level.
         * This is a Client Component boundary everything above this
         * line (html, body) is statically server-rendered. The provider
         * itself adds zero SSR cost; it activates only on the client.
         */}
        <MotionProvider>{children}</MotionProvider>
      </body>
    </html>
  );
}
// components/animations/PageTransition.tsx
"use client";

/**
 * CRITICAL: Import from "framer-motion" but use `m` not `motion`.
 * Within a LazyMotion boundary, `m.*` components use the deferred
 * feature bundle. `motion.*` components import the full synchronous bundle.
 * This single import distinction is the difference between 18kb and 90kb
 * of animation engine JavaScript reaching your users.
 */
import { m, AnimatePresence } from "framer-motion";
import { usePathname } from "next/navigation";

const pageVariants = {
  initial: {
    opacity: 0,
    y: 16,
    filter: "blur(4px)",
  },
  animate: {
    opacity: 1,
    y: 0,
    filter: "blur(0px)",
    transition: {
      duration: 0.45,
      ease: [0.25, 0.46, 0.45, 0.94], // Custom cubic-bezier for premium feel
    },
  },
  exit: {
    opacity: 0,
    y: -8,
    filter: "blur(2px)",
    transition: {
      duration: 0.25,
      ease: [0.55, 0, 1, 0.45],
    },
  },
} as const;

interface PageTransitionProps {
  children: React.ReactNode;
}

export default function PageTransition({ children }: PageTransitionProps) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait" initial={false}>
      {/*
       * Key on pathname ensures AnimatePresence detects the route change
       * and triggers exit animation on the outgoing page before mounting
       * the incoming page. Without the key, AnimatePresence cannot detect
       * that children have changed between navigations.
       *
       * mode="wait" ensures the exit animation completes before the
       * enter animation begins preventing both pages from rendering
       * simultaneously, which would cause layout shift.
       *
       * initial={false} prevents the entrance animation from firing
       * on the first page load the animation budget is reserved for
       * actual route transitions, not the initial render.
       */}
      <m.div
        key={pathname}
        variants={pageVariants}
        initial="initial"
        animate="animate"
        exit="exit"
        style={{ width: "100%" }}
      >
        {children}
      </m.div>
    </AnimatePresence>
  );
}
// components/animations/StaggerReveal.tsx
"use client";

import { m } from "framer-motion";

interface StaggerRevealProps {
  children: React.ReactNode;
  className?: string;
  delay?: number;
  staggerDelay?: number;
  /**
   * direction controls the axis of entry:
   * "up" elements rise from below (default, most common)
   * "down" elements descend from above (useful for dropdowns)
   * "left" elements enter from the right
   * "right" elements enter from the left
   */
  direction?: "up" | "down" | "left" | "right";
}

const directionOffsets = {
  up: { y: 40, x: 0 },
  down: { y: -40, x: 0 },
  left: { y: 0, x: 40 },
  right: { y: 0, x: -40 },
};

const containerVariants = (staggerDelay: number) => ({
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: staggerDelay,
      delayChildren: 0,
    },
  },
});

const itemVariants = (direction: StaggerRevealProps["direction"] = "up") => ({
  hidden: {
    opacity: 0,
    ...directionOffsets[direction!],
    filter: "blur(3px)",
  },
  visible: {
    opacity: 1,
    y: 0,
    x: 0,
    filter: "blur(0px)",
    transition: {
      duration: 0.55,
      ease: [0.25, 0.46, 0.45, 0.94],
    },
  },
});

/**
 * StaggerReveal orchestrates entrance animations for a group of children.
 * Each direct child is individually animated in sequence.
 * Usage:
 *
 * <StaggerReveal staggerDelay={0.1} direction="up">
 *   <Card />
 *   <Card />
 *   <Card />
 * </StaggerReveal>
 */
export function StaggerReveal({
  children,
  className,
  delay = 0,
  staggerDelay = 0.08,
  direction = "up",
}: StaggerRevealProps) {
  return (
    <m.div
      className={className}
      variants={containerVariants(staggerDelay)}
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: "-80px" }}
      style={{ transitionDelay: `${delay}s` }}
    >
      {Array.isArray(children)
        ? children.map((child, index) => (
            <m.div key={index} variants={itemVariants(direction)}>
              {child}
            </m.div>
          ))
        : children}
    </m.div>
  );
}

Performance Comparison: Standard Mounts vs. Optimised Framework Primers

The following table maps the measurable production impact of the architectural patterns covered in this guide against a baseline React SPA with naive animation library integration.

ParameterRaw Standard Mount (SPA / naive integration)Optimised Next.js 15 Framework Primer
Bundle Footprint (animations)90–160kb synchronous, main bundle18–45kb deferred, separate chunk
Main Thread Latency (parse + exec)300–800ms blocked at page load<20ms (deferred post-hydration)
Interaction to Next Paint (INP)Poor (>200ms) animation init blocks inputGood (<100ms) non-blocking execution
Cumulative Layout Shift (CLS)0.15–0.35 (hydration mismatch reflows)<0.01 (skeleton placeholders, scoped init)
Largest Contentful Paint (LCP)Delayed (bundle blocks critical path)Fast (server-rendered, no JS dependency)
Memory Leak RiskHigh (undisposed GSAP contexts on nav)None (useGSAP auto-disposes on unmount)
ScrollTrigger AccuracyInconsistent (race with hydration)Consistent (executes post-hydration)
SSR CompatibilityBroken (window refs in render path)Full (isomorphic hook isolation)
Lighthouse Performance Score45–65 (typical production SPA)90–98 (framework-optimised)
Developer Cleanup BurdenManual useEffect return requiredAutomatic via useGSAP context API
Tree-Shaking FidelityPoor (full library always loaded)High (LazyMotion feature bundles)
Route Transition SafetyAnimations persist across navigationsFully scoped, disposed on unmount

These numbers are derived from production profiling across comparable page types not synthetic benchmarks. The bundle footprint reduction alone produces a measurable LCP improvement on every page load for every user. The INP improvement compounds over a session as the main thread remains available for user interactions rather than blocked by animation engine initialisation.


The Business and Architectural ROI: Why Visual Performance Is a Revenue Variable

The engineering investment described in this guide is not cosmetic. It is financial.

Consider the conversion rate data: a 100-millisecond improvement in page load time correlates with a 1% increase in conversion rate for B2C e-commerce, and a 0.5–0.8% improvement in lead form submission rates for B2B SaaS. For an enterprise product generating $5M in annual revenue from web conversions, a 1.5% aggregate conversion improvement from performance optimisation represents $75,000 in annual incremental revenue recurring, compounding, and attributable to an engineering decision.

The visual quality dimension adds a separate multiplier. Premium motion design purposeful, non-gratuitous, architecturally constrained communicates product sophistication before a user reads a word of copy. It signals engineering maturity to technical evaluators and design sensibility to executive stakeholders. For agencies and software consultancies building custom high-performance applications from a clean architectural foundation , the animation system is part of the product quality signal.

The SEO dimension closes the loop. Google's Core Web Vitals are ranking signals in 2026 confirmed, measured, and increasingly weighted in competitive SERPs. Pages that pass all three thresholds Good LCP (<2.5s), Good INP (<200ms), Good CLS (<0.1) rank systematically above pages that fail them, assuming comparable content quality. Structural Core Web Vitals audits that repair field metric failures and restore search ranking positions are now a standard engagement for enterprise teams that let their performance debt accumulate.

The design system layer completes the picture. Premium aesthetic design systems that maintain visual integrity and consistency across all UI surfaces are not separable from the performance engineering conversation. Motion design that lives in a well-architected component library scoped, typed, cleanup-handled, bundle-optimised is design that can be maintained, extended, and audited without architectural regression. Motion design bolted onto an undifferentiated React SPA without context management or bundle isolation is design debt that compounds with every new page and every new developer who inherits the codebase.

The intersection of all three performance engineering, visual quality, and structural SEO is where genuine enterprise product differentiation is built. You can review live production implementations of this architecture across client projects to see the measurable outcomes across different industry verticals and product types.


Conclusion: The Ceiling Is Architectural, and So Is the Escape

The hydration ceiling is not a library limitation. GSAP is exceptional. Framer Motion is exceptional. The problem is never the tool it is the failure to account for the rendering lifecycle that Next.js App Router introduces and the performance constraints that Google's field metrics impose.

The patterns in this guide isomorphic layout effect isolation, next/dynamic SSR-disabled code splitting, useGSAP context scoping with automatic cleanup, and Framer Motion's LazyMotion feature bundle architecture are not optimisations applied after the fact. They are architectural decisions made at the beginning of a project, baked into the component structure, enforced by TypeScript, and validated in CI before any code reaches production.

Teams that implement these patterns build animation systems that scale: that survive route transitions without memory leaks, that survive Lighthouse audits without performance regressions, and that survive engineering team growth without becoming the hidden technical debt that slows every subsequent sprint.

Teams that do not implement these patterns eventually confront the ceiling usually after a stakeholder receives a Lighthouse report, or after Google Search Console surfaces a Core Web Vitals failure affecting 78% of mobile page views, or after a senior engineer spends a week diagnosing hydration errors that turn out to be GSAP initialising against a partially reconciled DOM.

The ceiling is architectural. So is the escape.

If your team is planning a migration from a React SPA to a Next.js App Router architecture or if you are evaluating how to integrate a premium animation system into an existing Next.js application without sacrificing the Core Web Vitals scores your SEO strategy depends on book a dedicated technical architectural consultation to map the migration path, define the bundle strategy, and establish the component architecture before a single line of animation code is written.

Architectural decisions made at the start of a project cost hours to implement correctly. Architectural decisions unmade at the start of a project cost months to fix.

Next Step

Let's build something exceptional together

Get in Touch