PGE-08Pages

縦スクロール連動の横スクロールヒーロー|止まって読ませる4面

React と GSAP ScrollTrigger で作る、縦スクロールに連動して横へ進むヒーロー。面ごとに一度止まり、見出し・文・数字・図版が順に表示されてから次の面へ送られる。等速の横移動に snap を足す一般的な実装と違い、タイムラインへ空のトゥイーンを挟んで縦スクロール量を「移動」と「静止」に配るため、止まっている間に読ませられる(移動1:静止0.6)。表紙には4面の縮小図、右上には面番号と面ごとの進み具合を置き、番号や縮小図を押すと同じ描画で選択が切り替わって面へ送られる(送りの途中で別の番号を押せば、そちらへ向かう)。固定は pinType: "transform" なので枠や余白のある入れ物からはみ出さず、文字は面の幅(cqw)を基準に決まる。画像素材ゼロで、図版は方眼とアンバーの図形で作図。動きを減らす設定では4面を縦に積み、一覧の縮小プレビューの中では表紙のまま止まる。GSAP は ScrollTrigger を含めて Standard License(2025年4月30日発効)のもとで商用でも無料で使える(出典: GSAP「Licensing」https://gsap.com/licensing/ 、2026年10月30日確認)。

プレビュー

00/ 03

表紙・停止中

00縦スクロールで横へ送る

下へ読むと、横へ進む4面

スクロールすると面が横へ送られ、面に着くたび一度止まって中身が順に開きます。番号か縮小図を押すと、その面へ直接移ります。

またはスクロール

4面の縮小図(押すと移動)

01縦から横への写像

縦のスクロールを横の送りへ写す

scrub でスクロール位置をタイムラインの時刻へ写します。面と面の間は power2.inOut で寄せ、送り出しと着地をゆるやかにします。

送りに使う縦の量
画面×3.45
追いつくまで(scrub)
0.6秒
図01縦スクロールの進み(左)が、4面の横位置(右)へ写る

02静止と順次表示

着いたら止まり、中身を順に開く

移動のあとに空のトゥイーンを挟むと、その区間はスクロールしても画面が動きません。その間に見出し・文・数字・図が順に開きます。

移動 : 静止
1 : 0.6
開くずらし(stagger)
0.09
図02スクロール量の配分。塗り=移動、白抜き=止まって読ませる区間

03固定と降格

枠の中に置いてもはみ出さない

固定は transform 方式なので、枠や余白のある入れ物でも画面全面へ飛び出しません。動きを減らす設定では4面を縦に積みます。

固定(pinType)
transform
動きを減らす設定
縦に4面
図03入れ物(点線)の中で固定される台紙と、その中を送られるレール

下へスクロール → 01 送る

00 / 03

X でシェア

説明の全文を読む

React と GSAP ScrollTrigger で作る、縦スクロールに連動して横へ進むヒーロー。面ごとに一度止まり、見出し・文・数字・図版が順に表示されてから次の面へ送られる。等速の横移動に snap を足す一般的な実装と違い、タイムラインへ空のトゥイーンを挟んで縦スクロール量を「移動」と「静止」に配るため、止まっている間に読ませられる(移動1:静止0.6)。表紙には4面の縮小図、右上には面番号と面ごとの進み具合を置き、番号や縮小図を押すと同じ描画で選択が切り替わって面へ送られる(送りの途中で別の番号を押せば、そちらへ向かう)。固定は pinType: "transform" なので枠や余白のある入れ物からはみ出さず、文字は面の幅(cqw)を基準に決まる。画像素材ゼロで、図版は方眼とアンバーの図形で作図。動きを減らす設定では4面を縦に積み、一覧の縮小プレビューの中では表紙のまま止まる。GSAP は ScrollTrigger を含めて Standard License(2025年4月30日発効)のもとで商用でも無料で使える(出典: GSAP「Licensing」https://gsap.com/licensing/ 、2026年10月30日確認)。

追加日:
2026-09-15
依存:
gsap
tags
#hero #page #horizontal #staggered-reveal #horizontal-scroll #scroll-linked #pinned #gsap #scrolltrigger #scrub #container-query #gallery #no-image #reduced-motion
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["500", "600", "700", "800"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 縦スクロール連動の横スクロールヒーロー(止まって読ませる4面) / Scroll-Linked Horizontal Hero
 *
 * ページ先頭のヒーロー自体を横送りにし、送り量を等速にしない: スクロール予算を「移動」と「静止」へ
 * 交互に配り、面に着いたら一度止まって、その面の中身(番号・見出し・リード・実数2つ・図版)が順に
 * 開いてから次へ動き出す。表紙=1面目で、表紙には4面の縮小図を置いて「この先に横へ何面あるか」を
 * 最初の画面で見せる。展示の3面は、この見本そのものの仕組み(送る・止まる・埋め込む)を図で説明する。
 * 数字はすべてこのコードの実際の設定値で、差し替えるときは frames を書き換える。
 *
 * 汎用技法メモ:
 * - **「止まる」はタイムラインの空トゥイーンで作る**: ScrollTrigger の scrub は進捗を時間へ写す
 *   だけなので、`tl.to({}, { duration: DWELL })` を挟むと、その区間ぶんスクロールしても画面は動かない
 *   =「読ませる間」になる。1変数(進捗 p)を transform へ直に掛ける実装や、等速の送りに snap を
 *   足す実装ではこの緩急を書けない。
 * - **ピン留めは pinType: "transform" を選ぶ**: 既定の fixed 固定は、枠線やパディングを持つ入れ物
 *   (埋め込みプレビュー・カード内)へ置いたとき要素が枠を飛び出して画面全面を覆う。transform 固定
 *   なら要素はフローに残ったまま「その場に留まって見える」ので、どこへ埋め込んでも破綻しない。
 * - **索引のクリックは「ラベル時刻 → 縦スクロール位置」の逆算で書く**: `st.start + (st.end - st.start)
 *   * (labelTime / tl.duration())` で、面の到着点に対応する縦位置が出る。横方向の入力を足さずに
 *   「02 へ飛ぶ」が成立する(送りの正は常にスクロール位置ひとつのまま)。
 * - **押した瞬間に応える**: 選択の表示は押下と同じ描画で切り替え、送り先を「錠」として持つ(途中の
 *   面を通過しても選択が揺れない・送りの途中で別の番号を押せば、錠を差し替えてそちらへ向かう)。
 *   面の上で止まっている間(静止区間)は何も動かないので、そこを時刻の早送りで飛ばしてから
 *   スクロール位置を一度に移し、見た目の追従は scrub に任せる。ブラウザの smooth スクロールに任せると
 *   静止区間を通り抜けるあいだ画面が動かず、押してから動き出すまで1秒近く待たされる。
 * - **縮小プレビューの中では固定を作らない**: 祖先に transform: scale があると getBoundingClientRect
 *   が縮尺ぶん小さい値を返し、ScrollTrigger の start/end が実寸とずれて一覧ページで勝手に動き出す。
 *   `rect.width / offsetWidth` が 1 から外れていたら初期化せず、表紙のまま静止させる。
 * - **降格先は「動きを止めた同じ画面」ではなく縦に積んだ読み物**: 横送りを止めるだけだと1面目で
 *   固まって残り3面が永久に読めない。reduced-motion では固定も横送りもやめ、面を縦に積む
 *   (マークアップは同じまま CSS だけで切り替わるので、JS は早期 return できる)。
 */

/** 1面ぶんの移動の長さ(タイムライン上の時刻) */
const MOVE = 1;
/** 着いてから次へ動き出すまでの静止の長さ */
const DWELL = 0.6;
/** 到着のどれだけ手前から中身を開き始めるか */
const REVEAL_LEAD = 0.42;
/** 中身が開く1つあたりの長さと、ずらし幅 */
const REVEAL_DUR = 0.5;
const STAGGER = 0.09;
/** 1面の送りに使う縦スクロール量(台紙の高さに対する倍率) */
const SCROLL_PER_FRAME = 1.15;
/** スクロールに追いつくまでの秒数 */
const SCRUB = 0.6;

type Fact = { label: string; value: string };

type Frame = {
  /** 索引と状態表示に出す通し番号 */
  no: string;
  /** 索引の和文ラベル */
  nav: string;
  /** 面の種別 */
  kind: string;
  /** 見出し。\n で改行する */
  heading: string;
  lead: string;
  facts: Fact[];
  /** 図版の作図。cover は図版を持たない */
  figure: "cover" | "map" | "dwell" | "nest";
  caption?: string;
};

const frames: Frame[] = [
  {
    no: "00",
    nav: "表紙",
    kind: "縦スクロールで横へ送る",
    heading: "下へ読むと、\n横へ進む4面",
    lead: "スクロールすると面が横へ送られ、面に着くたび一度止まって中身が順に開きます。番号か縮小図を押すと、その面へ直接移ります。",
    facts: [],
    figure: "cover",
  },
  {
    no: "01",
    nav: "送る",
    kind: "縦から横への写像",
    heading: "縦のスクロールを\n横の送りへ写す",
    lead: "scrub でスクロール位置をタイムラインの時刻へ写します。面と面の間は power2.inOut で寄せ、送り出しと着地をゆるやかにします。",
    facts: [
      { label: "送りに使う縦の量", value: `画面×${(SCROLL_PER_FRAME * 3).toFixed(2)}` },
      { label: "追いつくまで(scrub)", value: `${SCRUB}秒` },
    ],
    figure: "map",
    caption: "縦スクロールの進み(左)が、4面の横位置(右)へ写る",
  },
  {
    no: "02",
    nav: "止まる",
    kind: "静止と順次表示",
    heading: "着いたら止まり、\n中身を順に開く",
    lead: "移動のあとに空のトゥイーンを挟むと、その区間はスクロールしても画面が動きません。その間に見出し・文・数字・図が順に開きます。",
    facts: [
      { label: "移動 : 静止", value: `${MOVE} : ${DWELL}` },
      { label: "開くずらし(stagger)", value: `${STAGGER}` },
    ],
    figure: "dwell",
    caption: "スクロール量の配分。塗り=移動、白抜き=止まって読ませる区間",
  },
  {
    no: "03",
    nav: "埋め込む",
    kind: "固定と降格",
    heading: "枠の中に置いても\nはみ出さない",
    lead: "固定は transform 方式なので、枠や余白のある入れ物でも画面全面へ飛び出しません。動きを減らす設定では4面を縦に積みます。",
    facts: [
      { label: "固定(pinType)", value: "transform" },
      { label: "動きを減らす設定", value: "縦に4面" },
    ],
    figure: "nest",
    caption: "入れ物(点線)の中で固定される台紙と、その中を送られるレール",
  },
];

const LAST = String(frames.length - 1).padStart(2, "0");

/** 上に貼り付いたヘッダーの高さ。ヒーローの天井はそのぶん下がる(1面目がヘッダーの下へ潜らないように) */
function stickyHeaderHeight(): number {
  let offset = 0;
  document.querySelectorAll<HTMLElement>("header, [data-sticky-header]").forEach((el) => {
    const cs = getComputedStyle(el);
    if (cs.position !== "fixed" && cs.position !== "sticky") return;
    if (Math.abs(parseFloat(cs.top || "0")) > 1) return;
    const h = el.getBoundingClientRect().height;
    if (h > 0 && h < window.innerHeight * 0.3) offset = Math.max(offset, h);
  });
  return Math.round(offset);
}

/** 直近の「切り取られる箱」を探す(ページそのものは対象外) */
function findClipper(el: HTMLElement): HTMLElement | null {
  let node = el.parentElement;
  while (node && node !== document.body && node !== document.documentElement) {
    const oy = getComputedStyle(node).overflowY;
    if (oy === "auto" || oy === "scroll" || oy === "hidden") return node;
    node = node.parentElement;
  }
  return null;
}

function prefersReducedMotion(): boolean {
  return (
    typeof window !== "undefined" &&
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

/** 図版の中の小さな注記(縮小図では出さない) */
function Note({ className, children }: { className: string; children: string }) {
  return (
    <span
      className={`absolute text-[9px] leading-none font-bold whitespace-nowrap text-gray-500 sm:text-[10px] ${className}`}
    >
      {children}
    </span>
  );
}

/** 図版。画像素材を持たず、hairline の方眼+アンバーの図形だけで作る(面ごとに作図が違う) */
function Figure({ kind, mini = false }: { kind: Frame["figure"]; mini?: boolean }) {
  const grid = (
    <div
      className="absolute inset-0"
      style={{
        backgroundImage:
          "repeating-linear-gradient(to right, rgba(34,30,25,0.07) 0 1px, transparent 1px 20%), repeating-linear-gradient(to bottom, rgba(34,30,25,0.07) 0 1px, transparent 1px 25%)",
      }}
    />
  );
  return (
    <div aria-hidden="true" className="absolute inset-0 overflow-hidden">
      {grid}
      {kind === "cover" && (
        <div className="absolute inset-x-[14%] top-[24%] flex flex-col gap-[9%]">
          <span className="h-[3px] w-[30%] bg-amber-600" />
          <span className="h-1.5 w-[86%] bg-gray-900/70" />
          <span className="h-1.5 w-[64%] bg-gray-900/70" />
          <span className="h-px w-[80%] bg-gray-400" />
          <span className="h-px w-[72%] bg-gray-400" />
          <span className="mt-[6%] h-2 w-[34%] bg-amber-600" />
        </div>
      )}
      {kind === "map" && (
        <>
          {/* 左: 縦スクロールの軌道とつまみ */}
          <span className="absolute top-[14%] bottom-[14%] left-[10%] w-px bg-gray-300" />
          <span className="absolute top-[14%] left-[calc(10%-1px)] h-[24%] w-[3px] bg-amber-600" />
          {/* 写像の矢印: つまみ → 横の列 */}
          <span className="absolute top-[26%] left-[12%] h-[24%] w-px bg-amber-600" />
          <span className="absolute top-[50%] left-[12%] h-px w-[12%] bg-amber-600" />
          <span className="absolute top-[50%] left-[24%] h-0 w-0 -translate-y-1/2 border-y-[4px] border-l-[6px] border-y-transparent border-l-amber-600" />
          {/* 右: 横に並んだ4面。いま見えている1面だけ太枠 */}
          {[0, 1, 2, 3].map((n) => (
            <span
              key={n}
              className={`absolute top-[38%] h-[24%] w-[14%] ${
                n === 1 ? "border-2 border-amber-600 bg-white" : "border border-gray-400 bg-white/70"
              }`}
              style={{ left: `${28 + n * 16}%` }}
            />
          ))}
          {!mini && (
            <>
              <Note className="top-[6%] left-[6%]">縦スクロール</Note>
              {[0, 1, 2, 3].map((n) => (
                <span
                  key={n}
                  className="absolute top-[65%] text-[9px] leading-none font-bold text-gray-500 sm:text-[10px]"
                  style={{ left: `${28 + n * 16}%` }}
                >
                  {`0${n}`}
                </span>
              ))}
              <span className="absolute top-[80%] left-[28%] h-px w-[60%] bg-gray-400" />
              <span className="absolute top-[80%] left-[88%] h-0 w-0 -translate-y-1/2 border-y-[4px] border-l-[6px] border-y-transparent border-l-gray-400" />
              <Note className="top-[84%] left-[28%]">横の送り(4面)</Note>
            </>
          )}
        </>
      )}
      {kind === "dwell" && <DwellChart mini={mini} />}
      {kind === "nest" && (
        <>
          <div className="absolute inset-[8%] border border-dashed border-gray-400" />
          <div className="absolute inset-[22%] overflow-hidden border-2 border-amber-600 bg-white">
            {/* 台紙の中を送られるレール。右の面は台紙の縁で切れる */}
            {[0, 1, 2].map((n) => (
              <span
                key={n}
                className={`absolute top-[30%] h-[46%] w-[62%] border ${
                  n === 0 ? "border-gray-900/40 bg-[#faf7f2]" : "border-gray-300 bg-white"
                }`}
                style={{ left: `${8 + n * 70}%` }}
              />
            ))}
            <span className="absolute top-[42%] left-[14%] h-1 w-[30%] bg-gray-900/60" />
            <span className="absolute top-[56%] left-[14%] h-px w-[44%] bg-gray-400" />
          </div>
          {!mini && (
            <>
              <Note className="top-[10%] left-[10%]">入れ物(枠・余白)</Note>
              <Note className="top-[24%] left-[24%] text-amber-700">台紙(ここが固定)</Note>
              <Note className="bottom-[10%] left-[10%]">はみ出しは台紙の縁で切れる</Note>
            </>
          )}
        </>
      )}
    </div>
  );
}

/** 02 の図: スクロール量の配分を、タイムラインと同じ定数から描く */
function DwellChart({ mini }: { mini: boolean }) {
  // 1面ぶん = 移動 MOVE + 開き終わるまでの残り + 静止 DWELL
  const settle = Math.max(0, REVEAL_DUR + STAGGER * 4 - REVEAL_LEAD);
  const span = MOVE + settle + DWELL;
  const total = span * 3;
  const x0 = 8;
  const width = 84;
  const at = (t: number) => x0 + (t / total) * width;
  return (
    <>
      {[0, 1, 2].map((k) => {
        const start = k * span;
        return (
          <div key={k}>
            <span
              className="absolute top-[40%] h-[12%] bg-amber-600"
              style={{ left: `${at(start)}%`, width: `${(MOVE / total) * width}%` }}
            />
            <span
              className="absolute top-[40%] h-[12%] border border-gray-900/50 bg-white"
              style={{ left: `${at(start + MOVE)}%`, width: `${((settle + DWELL) / total) * width}%` }}
            />
            {/* 静止区間の中で順に開く中身(階段) */}
            {!mini &&
              [0, 1, 2, 3, 4].map((s) => (
                <span
                  key={s}
                  className="absolute h-[3px] bg-gray-900/45"
                  style={{
                    top: `${58 + s * 4}%`,
                    left: `${at(start + MOVE - REVEAL_LEAD + s * STAGGER)}%`,
                    width: `${(REVEAL_DUR / total) * width}%`,
                  }}
                />
              ))}
            {!mini && (
              <span
                className="absolute top-[30%] text-[9px] leading-none font-bold text-gray-500 sm:text-[10px]"
                style={{ left: `${at(start + MOVE)}%` }}
              >
                {`0${k + 1}`}
              </span>
            )}
          </div>
        );
      })}
      {!mini && (
        <>
          <Note className="top-[10%] left-[8%] text-amber-700">■ 移動</Note>
          <Note className="top-[10%] left-[30%]">□ 止まって読ませる</Note>
          <Note className="top-[84%] left-[8%]">縦スクロール量 →</Note>
        </>
      )}
    </>
  );
}

export default function ExhibitRailHero({
  headingTag: Heading = "h1",
}: {
  /**
   * 見出しのタグ。実案件ではそのまま(h1)。ギャラリーのプレビューは1ページに複数の標本が
   * 並ぶため "p" を渡して h1 の重複を避ける(見た目は className で決まるので変わらない)。
   * "p" で、しかも後ろにコードの欄(role="tabpanel")がある詳細ページのときだけ、台紙の上に
   * 操作の案内とコードへの近道を出す(実案件と一覧のカードでは出ない)。
   */
  headingTag?: "h1" | "p";
}) {
  const sectionRef = useRef<HTMLElement | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);
  const railRef = useRef<HTMLDivElement | null>(null);
  const frameRefs = useRef<(HTMLDivElement | null)[]>([]);
  /** ピン留めが動いているときだけ入る「面へ飛ぶ」関数(平置きでは null) */
  const jumpRef = useRef<((i: number) => void) | null>(null);
  /** 台紙より上の高さが変わったとき、固定の開始・終了位置を測り直す(平置きでは null) */
  const refreshRef = useRef<(() => void) | null>(null);
  const [active, setActive] = useState(0);
  /** フォーカスの受け口(onFocus)から、いまの面を描画を待たずに読む */
  const activeRef = useRef(0);
  /** 押して向かっている面(着いたら null)。途中で別の面を押すと差し替わる */
  const [target, setTarget] = useState<number | null>(null);
  /** 面の上で止まっているか、面と面の間を動いているか */
  const [moving, setMoving] = useState(false);
  const [mounted, setMounted] = useState(false);
  /** 詳細ページでコードの欄が見つかったときだけ、案内の帯と「コードへ」を出す */
  const [codeTarget, setCodeTarget] = useState<HTMLElement | null>(null);
  const total = frames.length;
  const isSpecimen = Heading === "p";

  // 表紙だけはマウント時にリビールする(スクロール連動のタイムラインには乗せない。
  // scrub の進捗0で from の初期値=非表示になり、ヒーローが読めなくなるため)
  useEffect(() => {
    const id = requestAnimationFrame(() => setMounted(true));
    return () => cancelAnimationFrame(id);
  }, []);

  useEffect(() => {
    if (!isSpecimen) return;
    const section = sectionRef.current;
    if (!section) return;
    const panel = Array.from(document.querySelectorAll<HTMLElement>('[role="tabpanel"]')).find(
      (el) => section.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING,
    );
    const id = requestAnimationFrame(() => setCodeTarget(panel?.closest("section") ?? panel ?? null));
    return () => cancelAnimationFrame(id);
  }, [isSpecimen]);

  // 案内の帯はマウント後に出るので、台紙が下へずれたぶん固定の位置を測り直す
  useEffect(() => {
    if (codeTarget) refreshRef.current?.();
  }, [codeTarget]);

  useEffect(() => {
    const section = sectionRef.current;
    const stage = stageRef.current;
    const rail = railRef.current;
    if (!section || !stage || !rail) return;
    // reduced-motion では固定も横送りも作らない(CSS 側が縦積みの読み物へ降格する)
    if (prefersReducedMotion()) return;
    // 縮小プレビュー(transform: scale した祖先)の中では初期化しない
    if (
      section.offsetWidth > 0 &&
      Math.abs(section.getBoundingClientRect().width / section.offsetWidth - 1) > 0.02
    ) {
      return;
    }
    // 固定する余地の無い入れ物へ埋め込まれたときも平置きのままにする
    const clipper = findClipper(section);
    if (clipper && clipper.clientHeight > 0 && clipper.clientHeight < stage.offsetHeight * 0.8) {
      section.classList.add("xr-flat");
      return;
    }

    let cancelled = false;
    let dispose: (() => void) | null = null;

    (async () => {
      const [{ gsap }, { ScrollTrigger }] = await Promise.all([
        import("gsap"),
        import("gsap/ScrollTrigger"),
      ]);
      if (cancelled) return;
      gsap.registerPlugin(ScrollTrigger);
      refreshRef.current = () => ScrollTrigger.refresh();

      const applyTop = () => {
        section.style.setProperty("--xr-top", `${stickyHeaderHeight()}px`);
      };
      applyTop();

      /** 押して向かっている面。onTick はこれがある間、選択の表示を動かさない */
      let lock: number | null = null;
      let lockTimer = 0;
      let scrollTween: gsap.core.Tween | null = null;
      const release = () => {
        if (lock === null) return;
        lock = null;
        window.clearTimeout(lockTimer);
        setTarget(null);
      };

      const ctx = gsap.context(() => {
        // 面の到着点(=止まって読ませる位置)の時刻。索引クリックの逆算にも使う
        const labelTimes: number[] = [0];
        // 面と面の間の移動の区間 [始まり, 終わり]。この外は画面が動かない
        const moves: [number, number][] = [];
        // 中身が開き終わる時刻(ここから次の移動までが、何も動かない静止区間)
        const settled: number[] = [0];

        // 読み取りは ScrollTrigger の onUpdate ではなくタイムライン側の onUpdate で行う。
        // scrub はスクロールが止まったあとも補間を続けるが、ScrollTrigger の onUpdate は
        // スクロール位置が変わったときしか呼ばれないので、止めた瞬間の値で固まってしまう。
        const onTick = () => {
          const t = tl.time();
          const duration = tl.duration() || 1;
          section.style.setProperty("--xr-p", (t / duration).toFixed(4));
          // 索引の下線に、その面の区間をどこまで進んだかを書く
          for (let k = 0; k < labelTimes.length; k += 1) {
            const from = labelTimes[k];
            const to = k + 1 < labelTimes.length ? labelTimes[k + 1] : duration;
            const f = to > from ? Math.min(1, Math.max(0, (t - from) / (to - from))) : 1;
            section.style.setProperty(`--xr-s${k}`, f.toFixed(3));
          }
          let nearest = 0;
          let best = Infinity;
          for (let i = 0; i < labelTimes.length; i += 1) {
            const d = Math.abs(labelTimes[i] - t);
            if (d < best) {
              best = d;
              nearest = i;
            }
          }
          const isMoving = moves.some(([a, b]) => t > a + 0.01 && t < b - 0.01);
          setMoving((prev) => (prev === isMoving ? prev : isMoving));
          if (lock !== null) {
            // 着いたら錠を外す。途中の面を通り過ぎても選択は送り先のまま
            if (Math.abs(t - settled[lock]) < 0.01) release();
            else return;
          }
          setActive((prev) => (prev === nearest ? prev : nearest));
        };

        const tl = gsap.timeline({ defaults: { ease: "none" }, onUpdate: onTick });

        for (let i = 1; i < total; i += 1) {
          const start = tl.duration();
          // 移動: 次の面へ寄せる。レール幅は total 面ぶんなので、1面 = 100/total %
          tl.to(rail, {
            xPercent: -(100 / total) * i,
            duration: MOVE,
            ease: "power2.inOut",
          });
          moves.push([start, tl.duration()]);
          labelTimes.push(tl.duration());
          // 到着の手前から、その面の中身を順に開く
          const reveals = frameRefs.current[i]?.querySelectorAll<HTMLElement>("[data-reveal]");
          if (reveals && reveals.length > 0) {
            tl.from(
              reveals,
              { y: 22, opacity: 0, duration: REVEAL_DUR, stagger: STAGGER, ease: "power2.out" },
              `-=${REVEAL_LEAD}`,
            );
          }
          settled.push(tl.duration());
          // 静止: ここを進んでも画面は動かない=読ませる間
          tl.to({}, { duration: DWELL });
        }

        const st = ScrollTrigger.create({
          trigger: stage,
          // 天井は画面の上端ではなく、貼り付いたヘッダーの下端
          start: () => `top ${stickyHeaderHeight()}px`,
          end: () => `+=${Math.round(stage.offsetHeight * (total - 1) * SCROLL_PER_FRAME)}`,
          pin: stage,
          // fixed 固定は枠線を持つ入れ物を飛び出して画面全面を覆う。transform 固定はフローに残る
          pinType: "transform",
          pinSpacing: true,
          anticipatePin: 1,
          scrub: SCRUB,
          animation: tl,
          invalidateOnRefresh: true,
          onRefreshInit: applyTop,
        });
        onTick();

        jumpRef.current = (i: number) => {
          const duration = tl.duration() || 1;
          // 行き先は到着の時刻ではなく、中身が開き終わった時刻(静止区間の入口)
          const goal = settled[i];
          const top = st.start + (st.end - st.start) * (goal / duration);
          lock = i;
          window.clearTimeout(lockTimer);
          // 何かの理由で着かなくても、選択の表示が固まったままにならないように
          lockTimer = window.setTimeout(release, 2400);
          scrollTween?.kill();
          scrollTween = null;

          const y = window.scrollY;
          if (y < st.start - 2 || y > st.end + 2) {
            // 台紙がまだ固定されていない(ページの途中に見えている)ときは、ページごと寄せる
            const proxy = { y };
            scrollTween = gsap.to(proxy, {
              y: top,
              // 立ち上がりの速い緩急にして、押した直後から台紙が寄り始めるようにする
              duration: 0.75,
              ease: "expo.out",
              onUpdate: () => window.scrollTo({ top: proxy.y, behavior: "instant" }),
            });
            return;
          }
          // 固定中: 送り先までの間にある静止区間を飛ばし、押した直後から動き出すようにする
          const now = tl.time();
          if (goal > now) {
            const nextMove = moves.find(([a]) => a >= now - 0.001);
            if (nextMove && nextMove[0] > now && nextMove[0] < goal) tl.time(nextMove[0]);
          } else if (goal < now) {
            const k = settled.findLastIndex((s) => s <= now + 0.001);
            if (k >= 0 && settled[k] < now - 0.001 && settled[k] > goal) tl.time(settled[k]);
          }
          window.scrollTo({ top, behavior: "instant" });
          ScrollTrigger.update();
        };
      }, section);

      // 利用者が自分でスクロールし始めたら、押した面への送りより手の操作を優先する
      const takeOver = (e: Event) => {
        if (e.type === "keydown") {
          const key = (e as KeyboardEvent).key;
          if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp", " ", "Home", "End"].includes(key)) return;
        }
        scrollTween?.kill();
        scrollTween = null;
        release();
      };
      window.addEventListener("wheel", takeOver, { passive: true });
      window.addEventListener("touchstart", takeOver, { passive: true });
      window.addEventListener("keydown", takeOver);

      dispose = () => {
        jumpRef.current = null;
        refreshRef.current = null;
        window.removeEventListener("wheel", takeOver);
        window.removeEventListener("touchstart", takeOver);
        window.removeEventListener("keydown", takeOver);
        window.clearTimeout(lockTimer);
        scrollTween?.kill();
        ctx.revert();
      };
    })();

    return () => {
      cancelled = true;
      dispose?.();
    };
  }, [total]);

  useEffect(() => {
    activeRef.current = active;
  }, [active]);

  const jumpTo = useCallback((i: number) => {
    // 選択の表示は押したのと同じ描画で切り替える(送りの到着を待たない)
    activeRef.current = i;
    setActive(i);
    if (jumpRef.current) {
      setTarget(i);
      jumpRef.current(i);
      return;
    }
    // 平置き(reduced-motion・埋め込み)では面そのものを縦に送る
    frameRefs.current[i]?.scrollIntoView({
      behavior: prefersReducedMotion() ? "auto" : "smooth",
      block: "center",
    });
  }, []);

  const current = frames[active] ?? frames[0];
  const heading = target !== null ? frames[target] : null;
  const next = frames[active + 1];
  /** 表紙だけのリビール(CSS のみ。JS が動かない環境・reduced-motion では完成状態で出る) */
  const coverDelays = ["delay-[0ms]", "delay-[120ms]", "delay-[260ms]", "delay-[400ms]"];
  const coverStep = (step: number) =>
    [
      "transition-[transform,opacity] duration-700 ease-out",
      coverDelays[step] ?? "delay-[400ms]",
      mounted ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0",
      "motion-reduce:translate-y-0 motion-reduce:opacity-100 motion-reduce:transition-none",
    ].join(" ");
  const focusRing =
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";

  return (
    <section ref={sectionRef} aria-labelledby="xr-title" className="xr w-full bg-white">
      <style>{`
        .xr-bar { transform: scaleX(var(--xr-p, 0)); }
        .xr-seg { transform: scaleX(var(--xr-s, 0)); transition: transform 80ms linear; }
        .xr-sent { transform: scaleX(1); }
        @keyframes xr-nudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
        .xr-nudge { animation: xr-nudge 1.6s ease-in-out infinite; }
        /* 平置きへの降格: 固定も横送りもやめ、面を縦に積んだ読み物にする(マークアップは同じ) */
        .xr-flat .xr-stage { height: auto !important; overflow: visible !important; }
        .xr-flat .xr-rail { width: 100% !important; flex-direction: column; transform: none !important; }
        .xr-flat .xr-frame {
          width: 100% !important;
          height: auto !important;
          padding-top: 3rem !important;
          padding-bottom: 3rem !important;
          border-bottom: 1px solid #e5e7eb;
        }
        .xr-flat .xr-band { position: static !important; }
        .xr-flat .xr-bar, .xr-flat .xr-seg { transform: none; }
        .xr-flat .xr-motion { display: none !important; }
        @media (prefers-reduced-motion: reduce) {
          .xr .xr-stage { height: auto !important; overflow: visible !important; }
          .xr .xr-rail { width: 100% !important; flex-direction: column; transform: none !important; }
          .xr .xr-frame {
            width: 100% !important;
            height: auto !important;
            padding-top: 3rem !important;
            padding-bottom: 3rem !important;
            border-bottom: 1px solid #e5e7eb;
          }
          .xr .xr-band { position: static !important; }
          .xr .xr-bar, .xr .xr-seg { transform: none; }
          .xr .xr-motion { display: none !important; }
          .xr .xr-nudge { animation: none; }
        }
      `}</style>

      {isSpecimen && codeTarget && (
        <div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-1 border-x border-t border-gray-200 bg-[#faf7f2] px-4 py-2 sm:px-5">
          <p className="xr-motion flex items-center gap-2 text-[12px] leading-snug font-bold text-gray-900 sm:text-[13px]">
            <span aria-hidden="true" className="xr-nudge inline-block text-amber-600">
              ↓
            </span>
            <span>
              下へスクロールすると横へ進み、各面で一度止まります
              <span className="font-medium text-gray-600">(右上の 00〜03 で直接移動)</span>
            </span>
          </p>
          <button
            type="button"
            onClick={() =>
              // 貼り付いたヘッダーの下に潜らない位置で止める
              window.scrollTo({
                top:
                  codeTarget.getBoundingClientRect().top +
                  window.scrollY -
                  stickyHeaderHeight() -
                  12,
                behavior: prefersReducedMotion() ? "auto" : "smooth",
              })
            }
            className={`inline-flex min-h-11 items-center text-[12px] font-bold text-amber-800 underline decoration-amber-600/40 underline-offset-4 hover:decoration-amber-600 sm:text-[13px] ${focusRing}`}
          >
            コードへ進む
          </button>
        </div>
      )}

      {/* 固定すると ScrollTrigger が台紙を pin-spacer で包む(DOM を付け替える)。台紙を React が持つ箱に
          入れておき、案内の帯が後から台紙の前へ差し込まれても、React の insertBefore が付け替え後の
          親子関係で落ちないようにする(以前は 375px で帯の表示と固定の作成が前後し、作品ごと落ちた) */}
      <div>
        <div
          ref={stageRef}
          className="xr-stage relative h-[calc(100svh-var(--xr-top,0px))] w-full overflow-hidden border-y border-gray-200 bg-white"
        >
          {/* 上帯: いまの面と状態(左)、索引(右)。レールには乗らず、面が変わっても位置が動かない */}
          <div className="xr-band pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 px-5 pt-4 sm:px-8 sm:pt-6">
            <div aria-live="polite" className="min-w-0 pt-1">
              <p className={`${montserrat.className} flex items-baseline gap-1 tabular-nums`}>
                <span className="text-[20px] leading-none font-extrabold text-amber-600 sm:text-[24px]">
                  {current.no}
                </span>
                <span className="text-[11px] font-bold text-gray-400">/ {LAST}</span>
              </p>
              <p className="xr-motion mt-1.5 flex items-center gap-1.5 text-[10px] leading-none font-bold whitespace-nowrap text-gray-600 sm:text-[11px]">
                <span
                  aria-hidden="true"
                  className={`h-1.5 w-1.5 ${heading || moving ? "bg-amber-600" : "border border-gray-500"}`}
                />
                {heading ? `${heading.no} ${heading.nav}へ移動中` : moving ? "移動中" : `${current.nav}・停止中`}
              </p>
            </div>

            <nav
              aria-label="面の索引"
              className="pointer-events-auto flex shrink-0 items-start gap-1 sm:gap-2"
            >
              {frames.map((frame, i) => {
                const isActive = active === i;
                return (
                  <button
                    key={frame.no}
                    type="button"
                    onClick={() => jumpTo(i)}
                    aria-current={isActive ? "true" : undefined}
                    aria-label={`${frame.no} ${frame.nav}`}
                    className={`flex min-h-11 w-11 flex-col items-start justify-start gap-1 px-1 pt-1 text-left sm:w-[3.75rem] ${focusRing}`}
                  >
                    <span
                      className={`${montserrat.className} text-[11px] font-bold tabular-nums ${
                        isActive ? "text-amber-600" : "text-gray-400"
                      }`}
                    >
                      {frame.no}
                    </span>
                    <span
                      className={`text-[10px] leading-none font-medium whitespace-nowrap ${
                        isActive ? "text-gray-900" : "text-gray-500"
                      }`}
                    >
                      {frame.nav}
                    </span>
                    {/* 下線=その面の区間をどこまで進んだか。押した面は即座に満たして送り先を示す */}
                    <span aria-hidden="true" className={`mt-0.5 h-0.5 w-full ${isActive ? "bg-amber-200" : "bg-gray-200"}`}>
                      <span
                        className={`block h-full w-full origin-left bg-amber-600 ${
                          target === i ? "xr-sent" : "xr-seg"
                        }`}
                        style={target === i ? undefined : ({ "--xr-s": `var(--xr-s${i}, 0)` } as React.CSSProperties)}
                      />
                    </span>
                  </button>
                );
              })}
            </nav>
          </div>

          {/* レール: 面を横一列に並べ、スクロール進捗で寄せる */}
          <div
            ref={railRef}
            className="xr-rail flex h-full"
            style={{ width: `${total * 100}%` }}
          >
            {frames.map((frame, i) => (
              <div
                key={frame.no}
                ref={(el) => {
                  frameRefs.current[i] = el;
                }}
                className="xr-frame relative flex h-full flex-col justify-center px-5 pt-[4.75rem] pb-[3.75rem] sm:px-8 sm:pt-24 sm:pb-20 lg:px-16"
                // 面そのものを寸法の基準にする(cqw)。ビューポート基準の vw だと、枠のある入れ物へ
                // 埋め込んだとき見出しだけが入れ物より大きく算出されて折り返す
                style={{ width: `${100 / total}%`, containerType: "inline-size" }}
              >
                {frame.figure === "cover" ? (
                  <div
                    // 別の面を見ている間、表紙のボタンはレールごと画面の外にある。Tab でそこへ入ったら
                    // 表紙へ送り戻し、フォーカスした要素を見える位置へ出す(マウスの押下は :focus-visible
                    // にならないので、縮小図を続けて押したときに表紙へ引き戻されることはない)
                    onFocus={(e) => {
                      if (activeRef.current !== 0 && jumpRef.current && e.target.matches(":focus-visible")) {
                        jumpTo(0);
                      }
                    }}
                    className="mx-auto flex w-full max-w-[1120px] flex-col gap-6 @4xl:flex-row @4xl:items-center @4xl:gap-14"
                  >
                    <div className="@4xl:w-[48%]">
                      <p
                        className={`flex items-baseline gap-3 border-b border-gray-200 pb-3 ${coverStep(0)}`}
                      >
                        <span
                          className={`${montserrat.className} text-[13px] font-extrabold tabular-nums text-amber-600`}
                        >
                          {frame.no}
                        </span>
                        <span className="text-[11px] font-bold text-gray-900 sm:text-xs">{frame.kind}</span>
                      </p>
                      <Heading
                        id="xr-title"
                        className={`mt-5 text-[clamp(1.6rem,7.6cqw,3.3rem)] leading-[1.18] font-bold tracking-tight text-gray-900 @4xl:text-[clamp(2.2rem,4.8cqw,4rem)] ${coverStep(1)}`}
                      >
                        {frame.heading.split("\n").map((line) => (
                          <span key={line} className="block">
                            {line}
                          </span>
                        ))}
                      </Heading>
                      <p
                        className={`mt-4 max-w-[40ch] text-[13.5px] leading-[1.85] font-medium text-gray-700 sm:mt-6 sm:text-[16px] sm:leading-[1.95] ${coverStep(2)}`}
                      >
                        {frame.lead}
                      </p>
                      <div className={`mt-5 flex flex-wrap items-center gap-x-6 gap-y-2 sm:mt-8 ${coverStep(3)}`}>
                        <button
                          type="button"
                          onClick={() => jumpTo(1)}
                          className={`inline-flex min-h-11 items-center bg-amber-600 px-7 text-sm font-bold text-white transition-colors hover:bg-amber-700 active:bg-amber-800 ${focusRing}`}
                        >
                          横へ送って見る
                        </button>
                        <p className="xr-motion flex items-center gap-2 text-[12px] font-bold text-gray-600">
                          <span aria-hidden="true" className="xr-nudge inline-block text-amber-600">
                            ↓
                          </span>
                          またはスクロール
                        </p>
                      </div>
                    </div>

                    {/* 4面の縮小図。横に何面続くかを最初の画面で見せ、押すとその面へ送る
                        (表紙は常に画面内なので、ここだけ操作要素を置いてもフォーカスが画面外の面へ飛ばない) */}
                    <div className={`@4xl:w-[52%] ${coverStep(3)}`}>
                      <p className="flex items-center justify-between text-[10px] font-bold text-gray-500 sm:text-[11px]">
                        <span>4面の縮小図(押すと移動)</span>
                        <span aria-hidden="true" className={`${montserrat.className} tabular-nums`}>
                          → →
                        </span>
                      </p>
                      <ul aria-label="4面へ移動" className="mt-2 grid grid-cols-4 gap-1.5 sm:gap-2.5">
                        {frames.map((f, k) => (
                          <li key={f.no}>
                            <button
                              type="button"
                              onClick={() => jumpTo(k)}
                              aria-label={`${f.no} ${f.nav}: ${f.heading.replace("\n", "")}`}
                              className={`group block w-full text-left ${focusRing}`}
                            >
                              <span
                                className={`relative block aspect-[4/3] bg-[#faf7f2] transition-colors ${
                                  active === k
                                    ? "border-2 border-amber-600"
                                    : "border border-gray-300 group-hover:border-gray-500"
                                }`}
                              >
                                <Figure kind={f.figure} mini />
                              </span>
                              <span className="mt-1.5 flex items-baseline gap-1.5">
                                <span
                                  className={`${montserrat.className} text-[10px] font-extrabold tabular-nums ${
                                    active === k ? "text-amber-600" : "text-gray-400"
                                  }`}
                                >
                                  {f.no}
                                </span>
                                <span className="truncate text-[10px] font-bold text-gray-800 sm:text-[11px]">
                                  {f.nav}
                                </span>
                              </span>
                              <span className="mt-0.5 hidden truncate text-[10px] font-medium text-gray-500 @xl:block">
                                {f.heading.replace("\n", "")}
                              </span>
                            </button>
                          </li>
                        ))}
                      </ul>
                    </div>
                  </div>
                ) : (
                  <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-5 @xl:flex-row @xl:items-center @xl:gap-10 @4xl:gap-14">
                    <div className="@xl:w-[52%]">
                      <p
                        data-reveal
                        className="flex items-baseline gap-3 border-b border-gray-200 pb-3"
                      >
                        <span
                          className={`${montserrat.className} text-[13px] font-extrabold tabular-nums text-amber-600`}
                        >
                          {frame.no}
                        </span>
                        <span className="text-[11px] font-bold text-gray-900 sm:text-xs">{frame.kind}</span>
                      </p>
                      <h2
                        data-reveal
                        className="mt-4 text-[clamp(1.4rem,6.4cqw,2.4rem)] leading-[1.3] font-bold tracking-tight text-gray-900 @xl:text-[clamp(1.5rem,3.6cqw,2.9rem)]"
                      >
                        {frame.heading.split("\n").map((line) => (
                          <span key={line} className="block">
                            {line}
                          </span>
                        ))}
                      </h2>
                      <p
                        data-reveal
                        className="mt-3 max-w-[44ch] text-[13px] leading-[1.8] font-medium text-gray-700 sm:mt-4 sm:text-[15px] sm:leading-[1.95]"
                      >
                        {frame.lead}
                      </p>
                      <dl
                        data-reveal
                        className="mt-4 grid max-w-md grid-cols-2 gap-x-6 border-t border-gray-200 pt-3 sm:mt-6 sm:pt-4"
                      >
                        {frame.facts.map((fact) => (
                          <div key={fact.label}>
                            <dt className="text-[10px] font-medium text-gray-500 sm:text-[11px]">{fact.label}</dt>
                            <dd
                              className={`${montserrat.className} mt-1 text-[clamp(1.1rem,4.6cqw,1.7rem)] leading-none font-extrabold tabular-nums text-gray-900 @xl:text-[clamp(1.2rem,2.6cqw,1.9rem)]`}
                            >
                              {fact.value}
                            </dd>
                          </div>
                        ))}
                      </dl>
                    </div>

                    <figure data-reveal className="@xl:w-[48%]">
                      <div className="relative h-[clamp(120px,24svh,240px)] w-full border border-gray-200 bg-[#faf7f2] @xl:h-[clamp(220px,44svh,440px)]">
                        <Figure kind={frame.figure} />
                      </div>
                      {frame.caption && (
                        <figcaption className="mt-2 flex gap-2 text-[10px] leading-relaxed font-medium text-gray-500 sm:text-[11px]">
                          <span className={`${montserrat.className} shrink-0 font-bold text-gray-700`}>
                            図{frame.no}
                          </span>
                          {frame.caption}
                        </figcaption>
                      )}
                    </figure>
                  </div>
                )}
              </div>
            ))}
          </div>

          {/* 下帯: 送り全体の進み具合と、次に何が来るか */}
          <div className="xr-band pointer-events-none absolute inset-x-0 bottom-0 z-20 px-5 pb-4 sm:px-8 sm:pb-6">
            <div className="h-px w-full bg-gray-200">
              <div aria-hidden="true" className="xr-bar h-px w-full origin-left bg-amber-600" />
            </div>
            <div className="mt-2.5 flex items-end justify-between gap-4">
              <p className="xr-motion text-[11px] font-medium text-gray-600 sm:text-xs">
                {next ? (
                  <>
                    下へスクロール →{" "}
                    <span className={`${montserrat.className} font-bold text-gray-900`}>{next.no}</span>{" "}
                    {next.nav}
                  </>
                ) : (
                  "最後の面です。上へスクロールで戻ります"
                )}
              </p>
              <p className={`${montserrat.className} text-[11px] font-bold tabular-nums text-gray-900`}>
                {current.no}
                <span className="text-gray-400"> / {LAST}</span>
              </p>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/exhibit-rail-hero.json