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段階で説明しています。
ピン留めする要素を包む。 同じ大きさの
<div class="pin-spacer">で包むので、要素が通常の流れから外れても、周りは潰れません。余白を足す。 スペーサーの下(
horizontal: trueのときは右)に、ピンが続く長さと同じ余白を足します。1800px続くピンなら余白は1800pxです。次のセクションは下へ押し出され、ピンが終わると追いつきます。ピンが有効なあいだ、 要素は
position: fixedになり、top、left、width、height が固定されます。スクロールの基準がビューポートでないときは、GSAPはtransformを使います。リサイズのとき、 ScrollTriggerはスペーサーを外し、元のCSSで測り直し、戻します。
実際のブラウザでは、こうなります。Chrome(ビューポート 1425 × 813)で pin: true, start: "top top", end: "+=1800" のときの、省略した抜粋です。
<!-- 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でよく起きる問題の直し方
症状から見て、原因を確かめます。
症状 | ありそうな原因 | 直し方 |
|---|---|---|
ピン留めしたセクションのあとに大きな空白 | スペーサーの余白がピンの距離( |
|
下の内容がピンに重なり、余白が付かない | ピン留めした要素の親が | ピン留めする要素を、普通のブロックの |
ピン留めした要素がずれ、ページと一緒に流れる | 祖先に | 祖先から外す。最後の手段として |
ピンより下のトリガーが早すぎる、または遅すぎる | ScrollTriggerをページの順番どおりに作っていない、または測ったあとに内容が読み込まれた | 上から順に作る。 |
開発中にピンが二重になり、画面遷移のあと壊れる | アンマウント時の後始末がない | ピンを |
リサイズのあと、開始と終了のマーカーがずれる |
|
|
速いスクロールで、ピン前の絵が一瞬見える | ピンが効く前にブラウザが描画する |
|
直し方1:白い余白はピンの距離そのもの
余白は、start と end のあいだのスクロール距離です。空白が長すぎると感じたら、end を短くします。次のセクションを待たせず、ピン留めしたセクションの上を滑らせたいなら、余白を切ります。
"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 の基準を変えている祖先を列挙します。
// 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ステップのセクションは、両方に従っています。
"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 は、まさにこれを設定します。外すか、上書きします。
html {
scroll-behavior: auto !important;
}Lenisを使うなら、LenisのREADME のとおりScrollTriggerと同期します。レイアウトに一度だけ置くクライアント部品です。
"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の | CSSの | |
|---|---|---|
追加のDOM |
| なし |
留まる長さ |
| 親コンテナの高さ |
JavaScript | 必要。 | 不要。サーバーコンポーネントで動く |
壊れるとき | 祖先に | 祖先に |
スクラブするタイムラインとの同期 | 組み込み( | 別のスクロール連動のコードと組み合わせる |
向いているもの | 複数ステップ、スナップ、横方向 | テキストや画像が変わるあいだ留まるパネル |
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 |
入れる手順です。
コンポーネントのページで Copy for Cursor を押します(アカウントは不要です)。束にはファイルパス、npmのインストール行、ライブプレビューのURL、スクロール連動の動きを壊さないようエージェントに伝える指針が入っています(セクションをエージェントに渡す)。
CursorかClaude Codeに貼り、それを載せるページを指定します。
ライブプレビューと見比べます(プレビューと見比べる)。セクションが画面を留めなくなったら、直し方2のコンソールチェックを実行します。原因はたいてい、レイアウト側のラッパーです。
Plus なら貼り付けを飛ばせます。CursorかClaude CodeでDesignLayerのMCPをつなぎ(接続手順)、こう頼みます。
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 — ScrollTriggerのドキュメント(pin、pinSpacing、pinReparent、pinType、anticipatePin、refreshPriority、「How does pinning work under the hood?」、ピンが跳ねるときのFAQ): https://gsap.com/docs/v3/Plugins/ScrollTrigger/
GSAP — ScrollTriggerのヒントと落とし穴(作る順番、内容の読み込み後の refresh、scroll-behavior: smooth): https://gsap.com/resources/st-mistakes/
GSAP — Reactと useGSAP(自動の後始末、"use client"、Strict Mode): https://gsap.com/resources/React/
MDN — レイアウトと包含ブロック: https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Display/Containing_block
MDN — position(stickyと「スクロールの仕組み」): https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/position
MDN — overflow(clipはスクロールコンテナではない): https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow
Tailwind CSS — translate(v4)、translate(v3)、backdrop-blur、blur、will-change、overflow、scroll-behavior: https://tailwindcss.com/docs/translate · https://v3.tailwindcss.com/docs/translate · https://tailwindcss.com/docs/backdrop-filter-blur · https://tailwindcss.com/docs/filter-blur · https://tailwindcss.com/docs/will-change · https://tailwindcss.com/docs/overflow · https://tailwindcss.com/docs/scroll-behavior
LenisのREADME(GSAP ScrollTriggerとの同期、destroy()): https://github.com/darkroomengineering/lenis





