GSAP pin-spacer Explained: Why It Adds Space and How to Fix Pinning in React and Next.js

Published: October 21, 2026 · 12 min read

What GSAP's pin-spacer div does, why it adds white space, and how to fix jumping pins, flex parents and React cleanup in Next.js, with code to copy.

TL;DR: pin-spacer is the wrapper <div> that GSAP ScrollTrigger puts around every element you pin. It keeps the element's original size in the layout while the element switches to position: fixed, and by default it adds bottom padding equal to the pin distance, so the content below waits until the pin ends. Most "pin-spacer bugs" come from four places: that padding (turn it off with pinSpacing: false), an ancestor with transform, filter, backdrop-filter or will-change (these break position: fixed), a display: flex parent (GSAP turns pin spacing off there by default), and missing cleanup in React. In React and Next.js, create the pin inside useGSAP and pin an element inside a wrapper your component owns. If you'd rather skip the tuning, DesignLayer has pinned and sticky sections you can preview for free and copy into Cursor or Claude Code: 3 copies a day on Free, or unlimited with Plus at $9/month.

What is the pin-spacer div in GSAP?

When you set pin on a ScrollTrigger, GSAP changes your DOM. The ScrollTrigger docs ("How does pinning work under the hood?") describe it in four steps:

  1. It wraps the pinned element in a <div class="pin-spacer"> sized to match it, so nothing collapses when the element leaves the normal flow.

  2. It adds padding to the bottom of the spacer (the right, with horizontal: true) equal to how long the pin lasts. A pin that lasts 1800px gets 1800px of padding, which pushes the next section down so it "catches up" when the pin ends.

  3. While the pin is active, the element gets position: fixed with fixed top, left, width and height. If your scroller isn't the viewport, GSAP uses transforms instead.

  4. On resize, ScrollTrigger takes the spacer out, re-measures with your original CSS, and puts it back.

Here is what that looks like in a real browser. This is a trimmed excerpt from Chrome (viewport 1425 × 813) for pin: true, start: "top top", end: "+=1800":

html
<!-- Your markup -->
<div>
  <section class="h-svh">…</section>
</div>

<!-- After ScrollTrigger creates the pin (scrolled into the pinned range) -->
<div>
  <div class="pin-spacer" style="display: block; position: relative; box-sizing: border-box;
       width: 1425px; height: 2613px; padding: 0px 0px 1800px; …">
    <section class="h-svh" style="position: fixed; top: 0.001px; left: 0px;
         width: 1425px; height: 813px; …">…</section>
  </div>
</div>

The spacer is 2613px tall: the 813px section plus 1800px of padding. So the pin-spacer isn't a bug; it's how the rest of the page knows the pin exists. You can't pin without it, but you control what it adds.

Why does pinning break when you move it into React or Next.js?

You followed a CodePen, the pin worked, and you moved it into your app. Then a huge blank gap appeared after the section, or the pin jumped and scrolled away halfway, or it broke after you navigated back, or everything doubled in development.

If that sounds familiar, the pin itself is usually fine. A demo runs on a bare page; your app has layout wrappers, Tailwind classes, page transitions, maybe smooth scrolling, and React mounting things twice in development. Each can change how position: fixed or the spacer behaves.

How do you fix the most common pin-spacer problems?

Start with the symptom, then check the cause:

Symptom

Likely cause

Fix

Large empty space after the pinned section

The spacer's padding equals the pin distance (end)

Shorten end, or set pinSpacing: false if the next section should slide over

Content below overlaps the pin, no space added

The pinned element's parent is display: flex, where pinSpacing defaults to false

Wrap the pinned element in a plain block <div>

Pinned element shifts, then scrolls away with the page

An ancestor has transform, translate, scale, rotate, filter, backdrop-filter, perspective, will-change or contain

Remove it from the ancestors; pinReparent: true as a last resort

Triggers below the pin fire too early or too late

ScrollTriggers created out of page order, or content loaded after measuring

Create them top to bottom, use refreshPriority, call ScrollTrigger.refresh()

Doubled pins in development, broken pins after navigation

No cleanup when the component unmounts

Create the pin inside useGSAP

Start and end markers drift after a resize

scroll-behavior: smooth on <html>

Override it with scroll-behavior: auto

A flash of unpinned content on fast scrolls

The browser paints before the pin applies

anticipatePin: 1

Fix 1: The white space is the pin distance

The padding equals the scroll distance between start and end. If the gap feels too long, shorten end. If the next section should slide over the pinned one instead of waiting, turn the padding off:

tsx
"use client";

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

gsap.registerPlugin(useGSAP, ScrollTrigger);

export function StackedHero() {
  const root = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      ScrollTrigger.create({
        trigger: ".hero",
        pin: true,
        pinSpacing: false, // no padding: the next section slides over the hero
        start: "top top",
        end: "bottom top",
      });
    },
    { scope: root }
  );

  return (
    <div ref={root}>
      <section className="hero grid h-svh place-items-center bg-zinc-950 text-6xl text-white">Hero</section>
      <section className="relative z-10 h-svh bg-white text-zinc-950">Next section</section>
    </div>
  );
}

The next section needs its own background and a higher z-index. GSAP also accepts pinSpacing: "margin" if you'd rather have margin than padding.

Fix 2: The pin jumps because an ancestor breaks position: fixed

This is the most common one. A position: fixed element is normally placed relative to the viewport. But per MDN's containing block guide, if any ancestor has filter, backdrop-filter, transform, perspective, rotate, scale or translate set to anything other than none, a contain value of layout, paint, strict or content, a matching will-change, or content-visibility: auto, the fixed element is placed relative to that ancestor instead. Your pin then scrolls along with it.

In a Tailwind project, look for translate-*, scale-*, rotate-* or transform-gpu (v4 writes translate-* as the translate property, v3 as transform; both count), backdrop-blur-* (backdrop-filter), blur-* (filter), and will-change-transform on a layout wrapper. A page-transition wrapper in template.tsx whose transform is still set after it animates in does the same.

To find the culprit, paste this into DevTools on the broken page. It lists every ancestor of each pinned element that changes how position: fixed works:

js
// Run in the browser console while the page is open
(() => {
  const props = ["transform", "translate", "rotate", "scale", "perspective",
    "filter", "backdropFilter", "willChange", "contain", "contentVisibility"];
  document.querySelectorAll(".pin-spacer > *").forEach((pinned) => {
    for (let el = pinned.parentElement; el && el !== document.documentElement; el = el.parentElement) {
      const cs = getComputedStyle(el);
      const hits = props.filter((p) => {
        const v = cs[p];
        if (p === "willChange") return /transform|translate|rotate|scale|perspective|filter/.test(v);
        if (p === "contain") return /layout|paint|strict|content/.test(v);
        if (p === "contentVisibility") return v === "auto";
        return Boolean(v) && v !== "none";
      });
      if (hits.length) console.log(pinned, "<-", el, hits.map((p) => `${p}: ${cs[p]}`).join("; "));
    }
  });
})();

Remove the property from that ancestor, or move the section outside it. If you can't, pinReparent: true moves the pinned element to <body> while pinned. The GSAP docs call it expensive and a last resort: CSS rules that depend on nesting (like .section .panel p) stop matching during the pin.

Fix 3: A flex parent gets no spacing

Per the docs, if the pinned element's container is display: flex, pinSpacing is false by default. In our Chrome test, the same pin got padding: 0px inside a flex column and padding: 0px 0px 1800px once a plain <div> sat between the flex container and the section. If content overlaps your pin, look for flex flex-col on the direct parent.

Fix 4: Triggers below the pin start in the wrong place

A pin with spacing pushes everything below it down, so triggers further down must account for that distance. The GSAP docs recommend creating ScrollTriggers in page order. If a section loads later (for example through next/dynamic), give the pinned trigger refreshPriority: 1, or call ScrollTrigger.sort() (it sorts by refreshPriority, then start position) and then ScrollTrigger.refresh().

Content that changes height after measuring has the same effect: give images a width and height, and call ScrollTrigger.refresh() after layout-changing content loads. Avoid content-visibility: auto on pinned sections; ScrollTrigger can't measure it.

Fix 5: Clean up in React with useGSAP and a wrapper you own

In development, React Strict Mode runs effects twice. When we created a pin in a plain useEffect with no cleanup, Strict Mode left two ScrollTriggers on the same element. The GSAP docs list React cleanup as the first thing to check when a pin jumps.

`useGSAP` from @gsap/react fixes this: it reverts every animation and ScrollTrigger created inside it on unmount, and a killed ScrollTrigger restores the pin's DOM changes. It needs "use client" in the App Router.

The second habit: the pin-spacer is a node React didn't create, so pin an inner element, not the component's root. Our GSAP ScrollTrigger guide recommends the same. Here is a pinned four-step section that follows both rules:

tsx
"use client";

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

gsap.registerPlugin(useGSAP, ScrollTrigger);

const steps = ["Search", "Copy", "Paste", "Ship"];

export function PinnedSteps() {
  const root = useRef<HTMLDivElement>(null); // React owns this div. Never pin it.
  const panel = useRef<HTMLElement>(null); // GSAP pins this one and wraps it in .pin-spacer

  useGSAP(
    () => {
      const items = gsap.utils.toArray<HTMLElement>(".step");
      const tl = gsap.timeline({
        defaults: { ease: "none" },
        scrollTrigger: {
          trigger: panel.current,
          pin: panel.current,
          start: "top top",
          end: () => "+=" + window.innerHeight * (items.length - 1),
          scrub: 1,
          anticipatePin: 1,
          invalidateOnRefresh: true,
          markers: process.env.NODE_ENV === "development",
        },
      });

      // Animate the children, not the pinned element itself.
      items.forEach((item, i) => {
        if (i > 0) tl.from(item, { yPercent: 40, autoAlpha: 0 });
        if (i < items.length - 1) tl.to(item, { yPercent: -40, autoAlpha: 0 }, "+=0.5");
      });
    },
    { scope: root }
  );

  return (
    <div ref={root}>
      <section ref={panel} className="relative h-svh overflow-hidden bg-zinc-950 text-white">
        {steps.map((label, i) => (
          <p
            key={label}
            className="step absolute inset-0 grid place-items-center text-5xl font-semibold md:text-7xl"
          >
            {String(i + 1).padStart(2, "0")} {label}
          </p>
        ))}
      </section>
    </div>
  );
}
  • end is a function with invalidateOnRefresh: true, so the pin length (three viewport heights for four steps) is recalculated on resize.

  • Only the .step children animate. The docs warn that animating the pinned element itself throws off the measurements.

  • { scope: root } limits ".step" to this component. Markers show only in development.

Import PinnedSteps from a Server Component page.tsx; only this file runs in the browser.

Fix 6: Smooth scrolling and scroll-behavior

GSAP's tips page notes that scroll-behavior: smooth on <html> throws off positions after a resize: ScrollTrigger jumps to the top to re-measure, and smooth scrolling delays the jump. Tailwind's scroll-smooth class sets exactly that. Remove it, or override it:

css
html {
  scroll-behavior: auto !important;
}

If you use Lenis, sync it with ScrollTrigger as the Lenis README shows, in a client component mounted once in your layout:

tsx
"use client";

import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

export function SmoothScroll() {
  useEffect(() => {
    const lenis = new Lenis();
    lenis.on("scroll", ScrollTrigger.update);

    const tick = (time: number) => lenis.raf(time * 1000);
    gsap.ticker.add(tick);
    gsap.ticker.lagSmoothing(0);

    return () => {
      gsap.ticker.remove(tick);
      lenis.destroy();
    };
  }, []);

  return null;
}

Should you use GSAP pin or CSS position: sticky?

If a section only needs to hold still while its content changes, CSS position: sticky may be enough, with no spacer and no JavaScript for the hold:

GSAP pin (pin-spacer)

CSS position: sticky

Extra DOM

Adds a .pin-spacer wrapper

None

How long it holds

You set it with end; padding is added for you

The height of its parent container

Needs JavaScript

Yes, in a "use client" file

No, works in a Server Component

Breaks when

An ancestor has transform, filter, backdrop-filter or will-change

An ancestor has overflow: hidden, scroll or auto, or no top is set

Sync with a scrubbed timeline

Built in (scrub, snap, labels)

Pair it with separate scroll-linked code

Best for

Multi-step sequences, snapping, horizontal runs

A panel that holds while text or images change

Sticky has its own trap. Per MDN, a sticky element sticks to its nearest ancestor with a "scrolling mechanism" (overflow: hidden, scroll, auto or overlay), even if that ancestor doesn't scroll. A stray overflow-hidden is the usual reason sticky "does nothing"; Tailwind's overflow-clip clips without creating a scroll container. More in Next.js Scroll Gallery: Sticky, Scroll-Linked, Mobile-Ready.

Skip the tuning: pinned and sticky sections you can copy

Getting a pin right is mostly tuning: how long it holds, what moves while it's held, and how it behaves on a phone. If you'd rather start from a finished section, these DesignLayer components hold the screen while you scroll. Each has a free live preview; dependencies are as listed on each page:

Component

What it does

Dependencies beyond React + TypeScript + Tailwind CSS

Pin Spacer Works

A pinned full-screen works gallery: photos fly out of the distance toward the camera while a blurred backdrop crossfades behind them

GSAP

Music Video Pin Stack

Thumbnails rise from the bottom and layer in the center while track titles flicker on the sides, with progress dots

GSAP

Before After Scroll

The screen stays put while Before wipes into After, then a third photo grows to fill the view

GSAP

Temple Grounds Gallery

A sticky full-viewport gallery where paired photos wipe upward as you scroll

None

Onsen Scroll Band

A sticky full-bleed band where stills crossfade as you scroll

None

Collections Scroll Slider

Full-bleed backgrounds scroll while a pinned side panel syncs titles, product lists and packshots

Motion

To add one:

  1. Press Copy for Cursor on the component page (no account needed). The bundle has the file paths, the npm install line, the live preview URL, and guidelines that tell the agent not to break scroll-linked motion (Hand a section to your agent).

  2. Paste it into Cursor or Claude Code and name the page that mounts it.

  3. Compare with the live preview (Match the live preview). If the section stops holding the screen, run the console check from Fix 2: the cause is usually a wrapper in your layout.

With Plus, skip the paste: connect DesignLayer MCP in Cursor or Claude Code (setup) and ask:

text
Use DesignLayer get_component for pin-spacer-works.
Write each files[].path from the JSON, install npmDependencies,
and mount it in app/page.tsx below the hero. Keep the demo images,
and don't wrap it in anything with transform, filter or overflow-hidden.

search_catalog (for example "gallery" or "scroll") doesn't use a copy. get_component uses one, the same as the button (Fetch a component from chat).

How much does it cost on DesignLayer?

Browsing and live previews are free, with no account or card. Free includes 3 component copies a day (reset at 00:00 JST / 15:00 UTC). A scroll-story page with a pinned gallery, a sticky band and a before/after section is three copies, so it fits in one day on Free. For more than that, Plus is $9/month:

  • Unlimited component copies, on the site and over MCP

  • MCP from Cursor and Claude Code (not included on Free)

  • Unlimited video prompt copies

It renews monthly; cancel from your account page and it stays active through the period you paid for (Copy quota and plans).

FAQ

  • The <div class="pin-spacer"> ScrollTrigger wraps around a pinned element. It keeps the element's size in the layout while it's position: fixed, and by default adds padding equal to the pin distance so the following content waits.

Summary

  • pin-spacer holds a pinned element's size and, by default, adds padding equal to the pin distance.

  • White space: shorten end or use pinSpacing: false. Flex parent: wrap the pinned element in a block <div>.

  • Jumping pins: remove transform, filter, backdrop-filter or will-change from ancestors; pinReparent is the last resort.

  • React and Next.js: pin inside useGSAP in a "use client" file, pin an inner element, create triggers in page order.

  • Drop scroll-behavior: smooth, and sync Lenis with ScrollTrigger.update.

Want to skip the tuning? Open Pin Spacer Works or browse the component catalog, and try a pinned section with today's free copy.

Sources

Related guides

GSAP pin-spacer Explained: Fix Pinning in React and Next.js | DesignLayer