back / blog
7 min read

Animated landing pages with GSAP and ScrollTrigger — lessons from a motion-first build

A deep dive into building production-grade scroll animations with GSAP and Next.js, featuring smooth horizontal pinning and custom cursor mechanics.

I recently completed the frontend interface for Noir Studio, a high-end agency landing page that relies heavily on cinematic transitions and dense imagery. The creative brief called for a site that felt entirely continuous — sections morphing into each other, large layout grids sliding horizontally on vertical scroll, and micro-interactions reacting to the user's cursor instantly. Building this kind of fluid experience in a React-centric world without introducing heavy frame drops requires dropping standard declarative state habits.

If you try to drive complex scroll positions by hooking up native event listeners to React state variables, the virtual DOM diffing cycle will quickly bottleneck your main thread. For smooth motion design, you have to bypass the React reconciler entirely during the animation loop and speak to the DOM directly.

Why GSAP beats Framer Motion for heavy scroll orchestration

Framer Motion is an exceptional library for standard application interfaces. If you are animating layout changes, tracking unmount lifecycles via AnimatePresence, or implementing basic spring transitions on modal windows, it is the neatest tool available.

However, the moment you transition from basic element fades to deeply linked scroll timelines — where the progress of a dozen distinct properties is clamped explicitly to the user's physical trackpad position — Framer Motion begins to struggle under the weight of its own declarative abstraction.

GSAP (GreenSock Animation Platform) wins on high-end production landing pages for a very specific architectural reason: it updates the underlying inline styles of elements directly via a single, highly optimised internal ticking requestAnimationFrame loop. It completely skips React's rendering pipeline.

When a GSAP tween updates a transform matrix, React remains blissfully unaware. This direct-to-DOM execution prevents layout thrashing and ensures that even mobile browsers can maintain a flat 60fps execution path when dealing with complex multi-element staggers.

Implementing the pinned horizontal scroll container

The cornerstone layout pattern of the Noir Studio showcase is the pinned horizontal section. The user enters a vertical scroll section, the viewport locks solidly in place, and as they continue to scroll downwards, the content shifts laterally across the screen to reveal a linear project showcase.

To implement this reliably inside Next.js without causing accidental layout shifting or broken entry thresholds during page hydration, you must combine the @gsap/react hook utility with an explicit, un-eased timeline structure.

// The definitive pinned horizontal scroll pattern in Next.js
import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

export function HorizontalShowcase({ items }: { items: Project[] }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);

  useGSAP(() => {
    const scrollWidth = scrollRef.current?.scrollWidth || 0;
    const viewportWidth = window.innerWidth;
    const pinDistance = scrollWidth - viewportWidth;

    if (pinDistance <= 0) return;

    gsap.to(scrollRef.current, {
      x: -pinDistance,
      ease: 'none', // Critical: any easing here will desync the scroll tracking
      scrollTrigger: {
        trigger: containerRef.current,
        pin: true,
        scrub: 1, // Smoothly catches up with the scroll movement
        start: 'top top',
        end: () => `+=${pinDistance}`,
        invalidateOnRefresh: true, // Recalculates dynamically if the user resizes the browser
      }
    });
  }, { scope: containerRef });

  return (
    <div ref={containerRef} className="w-full overflow-hidden bg-black">
      <div ref={scrollRef} className="flex h-screen w-max items-center px-10 gap-8">
        {items.map(item => (
          <section key={item.id} className="w-[80vw] h-[70vh] flex-shrink-0 bg-neutral-900" />
        ))}
      </div>
    </div>
  );
}

There are two details in this setup that save hours of debugging. First, the timeline container animation must use ease: 'none'. If you leave the default power easing profiles turned on, the horizontal movement will accelerate and decelerate strangely in the middle of the trackpad pull instead of matching the user's hand accurately.

Second, invalidateOnRefresh: true is vital; it forces ScrollTrigger to clear out old pixel calculations and rebuild the scroll boundary limits from scratch the split-second a mobile device shifts its orientation from portrait to landscape.

Micro-interactions and the dual-element custom cursor

To complement the large full-screen structural transitions, I integrated an interactive custom cursor that follows the user's mouse position and warps shapes when hovering over call-to-action buttons.

Instead of tracking the mouse position inside a global mousemove React handler and storing coordinates in local reactive state — which triggers an entire component tree re-render on every single pixel shift — the tracking logic runs entirely inside an isolated, frame-throttled callback loop.

// High-performance custom cursor tracking using quickTo
export function CustomCursor() {
  const dotRef = useRef<HTMLDivElement>(null);

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

    // quickTo returns an optimised function that updates properties immediately
    const xTo = gsap.quickTo(dotRef.current, 'x', { duration: 0.2, ease: 'power3' });
    const yTo = gsap.quickTo(dotRef.current, 'y', { duration: 0.2, ease: 'power3' });

    const handleMouseMove = (e: MouseEvent) => {
      xTo(e.clientX);
      yTo(e.clientY);
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  });

  return <div ref={dotRef} className="fixed top-0 left-0 w-4 h-4 rounded-full bg-white pointer-events-none z-50 mix-blend-difference" />;
}

Using gsap.quickTo bypasses the overhead of traditional tween parsing. It instantiates an optimised internal pipe dedicated solely to updating a single CSS attribute.

By applying a slight delay to the lag speed on a secondary canvas ring element running right behind the main dot pointer, you create an elegant trailing kinetic effect that makes the site interface feel immediate and organic.

Trade-offs and what doesn't work

The biggest trade-off with a motion-first architecture is the absolute destruction of structural SEO layouts if you over-rely on JavaScript-driven element generation. If an asset container requires a completed GSAP timeline run just to insert its text contents or clear out a hidden class node, automated search engine scrapers will often index the page as a completely blank shell. Your markup must remain readable and perfectly structured in the raw server-side HTML stream; animation should only ever alter the visual orientation properties like transform, opacity, and scale.

Furthermore, combining heavy scroll pinning with standard CSS scroll-snapping mechanics is a recipe for disaster. The two layout architectures use conflicting calculation frameworks: CSS snap works natively via the browser's thread-level scroll position controller, while ScrollTrigger overrides the viewport rendering bounds using synthetic margins and padding injection to force the window into place. Trying to run both inside the same parent tree will result in violent, unfixable page stuttering on mobile iOS devices.

Closing take

GSAP and ScrollTrigger unlock unparalleled creative capability for landing pages, but motion should always serve as an enhancement to clean information design, never a replacement for it. Keep your layouts semantically sound from the server paint, protect your main thread by decoupling mouse loops via native pipe targets, and don't make your visitors sit through a five-second loading animation just to read a paragraph of text. The full implementation lives on the Noir Studio project page if you want to inspect how all the pieces fit together.