GSAPのpin-spacer解説:余白が付く理由と、ReactとNext.jsでピンを直す方法

公開日: 2026年10月21日 · 読了目安 12 分

GSAPのpin-spacerが余白を足す理由と、ピンが跳ねる、flexの親で隙間がない、Reactの後始末が漏れるときの直し方。Next.jsのコード付き。

まとめ: pin-spacer は、GSAPのScrollTriggerがピン留めした要素の周りに置くラッパーの <div> です。要素が position: fixed に切り替わっても、元の大きさはレイアウトに残ります。初期設定では、ピンが続く距離と同じだけの下余白を足すので、下の内容はピンが終わるまで待ちます。「pin-spacerの不具合」の多くは、次の4つから来ます。その余白(pinSpacing: false で消す)、祖先にある transform、filter、backdrop-filter、will-change(position: fixed が壊れる)、display: flex の親(ここではGSAPが初期状態でピンの余白を切る)、Reactでの後始末漏れ。ReactとNext.jsでは、ピンを useGSAP の中で作り、部品が自分で持つラッパーの内側の要素をピン留めします。調整を飛ばしたいときは、DesignLayerにピン留めとstickyのセクションがあります。無料でプレビューでき、CursorやClaude Codeにコピーできます。無料は1日3回、無制限は Plus(月額 $9) です。

GSAPのpin-spacerとは何か

pin を付けたScrollTriggerは、DOMを書き換えます。ScrollTriggerのドキュメント(「How does pinning work under the hood?」)は、これを4段階で説明しています。

  1. ピン留めする要素を包む。 同じ大きさの <div class="pin-spacer"> で包むので、要素が通常の流れから外れても、周りは潰れません。

  2. 余白を足す。 スペーサーの下(horizontal: true のときは右)に、ピンが続く長さと同じ余白を足します。1800px続くピンなら余白は1800pxです。次のセクションは下へ押し出され、ピンが終わると追いつきます。

  3. ピンが有効なあいだ、 要素は position: fixed になり、top、left、width、height が固定されます。スクロールの基準がビューポートでないときは、GSAPはtransformを使います。

  4. リサイズのとき、 ScrollTriggerはスペーサーを外し、元のCSSで測り直し、戻します。

実際のブラウザでは、こうなります。Chrome(ビューポート 1425 × 813)で 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>

スペーサーの高さは2613pxです。813pxのセクションに、1800pxの余白が足されています。pin-spacerは不具合ではなく、ページの残りに「ここにピンがある」と伝える仕組みです。ピンにすれば必ず付きます。足す量は自分で決められます。

ReactやNext.jsに移すと、なぜピンが崩れるのか

CodePenでは動いていたピンを、アプリに移した。するとセクションのあとに大きな空白が出る。途中で跳ねて、スクロールと一緒に流れる。戻ってくると壊れている。開発中だけ二重になる。

どれも、ピン自体はたいてい問題ありません。デモは何もないページで動きます。アプリにはレイアウトのラッパー、Tailwindのクラス、ページ遷移、スムーズスクロールがあり、開発中はReactがマウントを2回します。どれも position: fixed やスペーサーの振る舞いを変えます。

pin-spacerでよく起きる問題の直し方

症状から見て、原因を確かめます。

症状

ありそうな原因

直し方

ピン留めしたセクションのあとに大きな空白

スペーサーの余白がピンの距離(end)と同じ

end を短くする。次のセクションを重ねて流したいなら pinSpacing: false

下の内容がピンに重なり、余白が付かない

ピン留めした要素の親が display: flex で、pinSpacing の初期値が false

ピン留めする要素を、普通のブロックの <div> で包む

ピン留めした要素がずれ、ページと一緒に流れる

祖先に transform、translate、scale、rotate、filter、backdrop-filter、perspective、will-change、contain がある

祖先から外す。最後の手段として pinReparent: true

ピンより下のトリガーが早すぎる、または遅すぎる

ScrollTriggerをページの順番どおりに作っていない、または測ったあとに内容が読み込まれた

上から順に作る。refreshPriority を使う。ScrollTrigger.refresh() を呼ぶ

開発中にピンが二重になり、画面遷移のあと壊れる

アンマウント時の後始末がない

ピンを useGSAP の中で作る

リサイズのあと、開始と終了のマーカーがずれる

<html> の scroll-behavior: smooth

scroll-behavior: auto で上書きする

速いスクロールで、ピン前の絵が一瞬見える

ピンが効く前にブラウザが描画する

anticipatePin: 1

直し方1:白い余白はピンの距離そのもの

余白は、start と end のあいだのスクロール距離です。空白が長すぎると感じたら、end を短くします。次のセクションを待たせず、ピン留めしたセクションの上を滑らせたいなら、余白を切ります。

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>
  );
}

次のセクションには、自分の背景と、より高い z-index が要ります。余白ではなくマージンにしたいときは、GSAPは pinSpacing: "margin" も受け付けます。

直し方2:祖先が position: fixed を壊すとピンが跳ねる

いちばん多いケースです。position: fixed の要素は、通常ビューポートを基準に置かれます。ただし MDNの包含ブロックの解説 にあるとおり、祖先のどれかに filter、backdrop-filter、transform、perspective、rotate、scale、translate が none 以外で付いている、contain が layout、paint、strict、content である、それに対応する will-change がある、または content-visibility: auto があると、fixedの要素はその祖先を基準に置かれます。ピンは、その祖先と一緒にスクロールします。

Tailwindのプロジェクトでは、レイアウトのラッパーにある translate-*、scale-*、rotate-*、transform-gpu(v4は translate-* を translate プロパティとして書き、v3は transform として書きます。どちらも該当します)、backdrop-blur-*(backdrop-filter)、blur-*(filter)、will-change-transform を探します。template.tsx のページ遷移ラッパーで、入りのアニメーションのあとも transform が残っている場合も同じです。

壊れているページのDevToolsに、これを貼ります。ピン留めした各要素について、position: fixed の基準を変えている祖先を列挙します。

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("; "));
    }
  });
})();

その祖先からプロパティを外すか、セクションをその外へ移します。外せないときは、pinReparent: true がピンのあいだ要素を <body> へ移します。GSAPのドキュメントは、これを重く、最後の手段だとしています。入れ子に依存するCSS(.section .panel p のような指定)は、ピンのあいだ一致しなくなります。

直し方3:flexの親では余白が付かない

ドキュメントのとおり、ピン留めする要素のコンテナが display: flex のとき、pinSpacing の初期値は false です。Chromeでの確認では、同じピンがflexのカラムの中では padding: 0px になり、flexコンテナとセクションのあいだに普通の <div> を挟むと padding: 0px 0px 1800px になりました。内容がピンに重なるときは、直接の親に flex flex-col がないか見ます。

直し方4:ピンより下のトリガーがずれる

余白のあるピンは、下にあるものをすべて押し下げます。さらに下のトリガーは、その距離を織り込まなくてはなりません。GSAPのドキュメントは、ScrollTriggerをページの順番に作るよう勧めています。セクションがあとから読み込まれる場合(たとえば next/dynamic)、ピンのトリガーに refreshPriority: 1 を付けるか、ScrollTrigger.sort()(refreshPriority、次に開始位置で並べます)のあとに ScrollTrigger.refresh() を呼びます。

測ったあとに高さが変わる内容も、同じ影響があります。画像には幅と高さを付け、レイアウトが変わる内容の読み込み後に ScrollTrigger.refresh() を呼びます。ピン留めするセクションに content-visibility: auto は避けます。ScrollTriggerが測れません。

直し方5:useGSAPと、自分で持つラッパーで後始末する

開発中、ReactのStrict Modeはeffectを2回実行します。後始末のない普通の useEffect でピンを作ったところ、Strict Modeは同じ要素にScrollTriggerを2つ残しました。GSAPのドキュメントは、ピンが跳ねるときに最初に確かめることとして、Reactの後始末を挙げています。

@gsap/react の `useGSAP` がこれを直します。アンマウント時に、その中で作ったアニメーションとScrollTriggerをすべて戻します。killされたScrollTriggerは、ピンが加えたDOMの変更も戻します。App Routerでは "use client" が必要です。

もう一つの習慣です。pin-spacerはReactが作ったノードではないので、部品のルートではなく、内側の要素をピン留めします。GSAP ScrollTriggerのガイド も同じように勧めています。次の4ステップのセクションは、両方に従っています。

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 は関数にし、invalidateOnRefresh: true を付けます。ピンの長さ(4ステップならビューポート3つ分)は、リサイズのときに計算し直されます。

  • 動かすのは .step の子だけです。ドキュメントは、ピン留めした要素自体を動かすと計測がずれる、と警告しています。

  • { scope: root } は ".step" をこの部品の中に限定します。マーカーは開発中だけ出します。

PinnedSteps は、サーバーコンポーネントの page.tsx から import します。ブラウザで動くのはこのファイルだけです。

直し方6:スムーズスクロールと scroll-behavior

GSAPのヒントのページ は、<html> の scroll-behavior: smooth がリサイズ後の位置をずらす、と書いています。ScrollTriggerは測り直すために先頭へ飛び、スムーズスクロールがそのジャンプを遅らせます。Tailwindの scroll-smooth は、まさにこれを設定します。外すか、上書きします。

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

Lenisを使うなら、LenisのREADME のとおりScrollTriggerと同期します。レイアウトに一度だけ置くクライアント部品です。

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;
}

GSAPのpinと、CSSの position: sticky

セクションが留まり、そのあいだ内容だけ変わるなら、CSSの position: sticky で足りることがあります。留めるためのスペーサーも、JavaScriptも要りません。

GSAPの pin(pin-spacer)

CSSの position: sticky

追加のDOM

.pin-spacer のラッパーが付く

なし

留まる長さ

end で指定する。余白は自動で付く

親コンテナの高さ

JavaScript

必要。"use client" のファイル

不要。サーバーコンポーネントで動く

壊れるとき

祖先に transform、filter、backdrop-filter、will-change がある

祖先に overflow: hidden、scroll、auto がある、または top がない

スクラブするタイムラインとの同期

組み込み(scrub、snap、ラベル)

別のスクロール連動のコードと組み合わせる

向いているもの

複数ステップ、スナップ、横方向

テキストや画像が変わるあいだ留まるパネル

stickyにも落とし穴があります。MDN のとおり、stickyの要素は「スクロールの仕組み」を持つ最も近い祖先(overflow: hidden、scroll、auto、overlay)に貼り付きます。その祖先が実際にはスクロールしなくてもです。置き忘れた overflow-hidden が、stickyが「何もしない」いちばん多い理由です。Tailwindの overflow-clip は、スクロールコンテナを作らずに切り抜きます。詳しくは 【Next.js】スクロールギャラリーの作り方を解説|写真の固定とスマホ対応 にあります。

調整を飛ばす:コピーできるピンとstickyのセクション

ピンを揃える作業のほとんどは調整です。どれだけ留めるか、留まるあいだ何が動くか、スマホでどう振る舞うか。完成したセクションから始めたいときは、次のDesignLayerの部品が、スクロールのあいだ画面を留めます。どれも無料のライブプレビューがあり、依存は各ページの記載どおりです。

コンポーネント

すること

React + TypeScript + Tailwind CSS 以外の依存

ピンスペーサー・ワークス

ピン留めした全画面の作品ギャラリー。写真が奥からカメラへ飛び出し、ぼかした背景が後ろでクロスフェードする

GSAP

ミュージックビデオ・ピンスタック

サムネイルが下から昇って中央に重なり、曲名が左右で瞬く。進行のドット付き

GSAP

ビフォーアフター・スクロール

画面は留まったまま Before が After へワイプし、3枚目の写真が画面いっぱいに広がる

GSAP

境内ギャラリー

stickyの全画面ギャラリー。スクロールに合わせて、組になった写真が上へワイプする

なし

温泉スクロール帯

stickyの全幅帯。スクロールに合わせてスチルがクロスフェードする

なし

コレクション・スクロールスライダー

全幅の背景がスクロールし、ピン留めしたサイドパネルがタイトル、商品リスト、パックショットを同期する

Motion

入れる手順です。

  1. コンポーネントのページで Copy for Cursor を押します(アカウントは不要です)。束にはファイルパス、npmのインストール行、ライブプレビューのURL、スクロール連動の動きを壊さないようエージェントに伝える指針が入っています(セクションをエージェントに渡す)。

  2. CursorかClaude Codeに貼り、それを載せるページを指定します。

  3. ライブプレビューと見比べます(プレビューと見比べる)。セクションが画面を留めなくなったら、直し方2のコンソールチェックを実行します。原因はたいてい、レイアウト側のラッパーです。

Plus なら貼り付けを飛ばせます。CursorかClaude CodeでDesignLayerのMCPをつなぎ(接続手順)、こう頼みます。

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(たとえば "gallery" や "scroll")はコピーを消費しません。get_component はボタンと同じく1回分を使います(チャットからコンポーネントを取る)。

DesignLayerでの料金

閲覧とライブプレビューは無料で、アカウントもカードも要りません。無料はコンポーネントのコピーが1日3回までです(日本時間 0:00 / UTC 15:00 にリセット)。ピン留めのギャラリー、stickyの帯、ビフォーアフターを含むスクロールのページはコピー3回なので、無料の1日に収まります。それ以上は Plusが月額 $9 です。

  • コンポーネントのコピーは無制限。サイト上でもMCPでも

  • CursorとClaude CodeからのMCP(無料には含まれません)

  • 動画プロンプトのコピーは無制限

月ごとに更新されます。アカウントのページから解約でき、支払った期間の終わりまでは有効です(コピー回数とプラン)。

よくある質問

  • ScrollTriggerがピン留めした要素の周りに置く <div class="pin-spacer"> です。position: fixed のあいだも、要素の大きさはレイアウトに残します。初期設定ではピンの距離と同じ余白を足すので、後ろの内容はピンが終わるまで待ちます。

まとめ

  • pin-spacer はピン留めした要素の大きさを保ち、初期設定ではピンの距離と同じ余白を足します。

  • 白い余白は、end を短くするか pinSpacing: false を使います。flexの親なら、ピン留めする要素をブロックの <div> で包みます。

  • 跳ねるピンは、祖先から transform、filter、backdrop-filter、will-change を外します。pinReparent は最後の手段です。

  • ReactとNext.jsでは、"use client" のファイルの useGSAP の中でピンを作り、内側の要素をピン留めし、トリガーはページの順番に作ります。

  • scroll-behavior: smooth は外し、Lenisは ScrollTrigger.update と同期します。

調整を飛ばすなら、ピンスペーサー・ワークス を開くか、コンポーネントカタログ を見て、今日の無料コピーでピン留めのセクションを試してください。

出典

関連ガイド

GSAPのpin-spacer解説:余白とピンの直し方 | DesignLayer