First CH Designs
PGE-08Pages

Horizontal Exhibit Hero (Scrub-Paced Frames)

A hero where the opening screen itself becomes a horizontal gallery: the cover is frame one, and vertical scroll progress maps onto a four-frame rail. The pacing is deliberately uneven — the scroll budget alternates between travel and dwell, so the rail halts on arrival while each frame's label, heading, lead, two figures and diagram stagger in before it moves on. Dwells are empty tweens inside a scrubbed GSAP timeline, a rhythm you cannot express by multiplying a single progress variable into a transform. Pinning uses the transform method rather than fixed, so the stage never escapes a bordered or padded container. The cover's table of contents and the top index both jump to a frame by converting a timeline label's time back into a scroll offset, so scroll position stays the single source of truth and no horizontal input is added. Type scales against the frame itself via container query units, so an embedded stage never gets headings sized for the viewport. Zero image assets; diagrams are drawn from hairline rules and amber shapes. Inside scaled previews it stays on the cover, and under reduced motion it degrades to four frames stacked vertically.

Added:
2026-09-15
Dependencies:
gsap
tags
#hero #page #horizontal #scroll-linked #pinned #gsap #scrolltrigger #scrub #container-query #gallery #no-image #reduced-motion

Preview

事業案内

つくる前に決める、三つのこと

図面も、文章も、画面も。着手より前に判断の順序をそろえることで、後戻りの工数を持ち込まない仕事にしています。

要件定義

使う人の手順から起こす要件

机上の機能一覧ではなく、現場で実際にたどっている手順を写し取ってから要件へ落とします。使われない画面を作らないための最初の一手です。

現場同行
3回
要件の確定まで
14日

図: 手順の経路と、判断が起きる三つの地点

設計・合意

迷いを残さない判断の順序

決める順番と、決める人を先に固定します。後段の設計が前段の結論を掘り返さないので、合意のやり直しが起きません。

決裁の待ち時間
-62%
差し戻し
0.4回

図: 全体のうち、この工程で確定させる範囲

運用移管

引き渡したあとも読める設計

更新の手順を外側から内側へ、順に開いていきます。引き渡しの日を境に手が止まらないよう、運用の担い手ごと設計します。

運用移管
14日
更新の内製率
87%

図: 外側から順に開いていく更新の担当範囲

事業案内

00 / 03

"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面) / Horizontal Exhibit Hero
 *
 * 既存の横送り(BLK-32 / BLK-40 / BLK-57)はいずれも「ページ途中の実績セクション」で、送りは
 * 等速か利用者の横入力で進む。本ブロックは **ページ先頭のヒーロー自体を送りにし、送り量を等速に
 * しない**: スクロール予算を「移動」と「静止」へ交互に配り、面に着いたら一度止まって、その面の
 * 中身(種別・見出し・リード・実数2つ・図版)が順に開いてから次へ動き出す。表紙=1面目なので、
 * 冒頭が展示へ変わっていく読み口になる。配色・文言・数値はすべて架空のプレースホルダー。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **「止まる」はタイムラインの空トゥイーンで作る**: ScrollTrigger の scrub は進捗を時間へ写す
 *   だけなので、`tl.to({}, { duration: 0.6 })` を挟むと、その区間ぶんスクロールしても画面は動かない
 *   =「読ませる間」になる。1変数(進捗 p)を transform へ直に掛ける実装ではこの緩急を書けない。
 * - **ピン留めは pinType: "transform" を選ぶ**: 既定の fixed 固定は、枠線やパディングを持つ入れ物
 *   (埋め込みプレビュー・カード内)へ置いたとき要素が枠を飛び出して画面全面を覆う。transform 固定
 *   なら要素はフローに残ったまま「その場に留まって見える」ので、どこへ埋め込んでも破綻しない。
 * - **索引のクリックは「ラベル時刻 → 縦スクロール位置」の逆算で書く**: `st.start + (st.end - st.start)
 *   * (labelTime / tl.duration())` で、面の到着点に対応する縦位置が出る。横送りの実装に横方向の
 *   入力を足さずに「02 へ飛ぶ」が成立する(送りの正は常にスクロール位置ひとつのまま)。
 * - **縮小プレビューの中では固定を作らない**: 祖先に transform: scale があると getBoundingClientRect
 *   が縮尺ぶん小さい値を返し、ScrollTrigger の start/end が実寸とずれて一覧ページで勝手に動き出す。
 *   `rect.width / offsetWidth` が 1 から外れていたら初期化せず、表紙のまま静止させる。
 * - **降格先は「動きを止めた同じ画面」ではなく縦に積んだ読み物**: 横送りを止めるだけだと1面目で
 *   固まって残り3面が永久に読めない。reduced-motion では固定も横送りもやめ、面を縦に積む
 *   (マークアップは同じまま CSS だけで切り替わるので、JS は早期 return できる)。
 */

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

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

const frames: Frame[] = [
  {
    no: "00",
    nav: "表紙",
    kind: "事業案内",
    heading: "つくる前に決める、\n三つのこと",
    lead: "図面も、文章も、画面も。着手より前に判断の順序をそろえることで、後戻りの工数を持ち込まない仕事にしています。",
    facts: [],
    figure: "cover",
  },
  {
    no: "01",
    nav: "調べる",
    kind: "要件定義",
    heading: "使う人の手順から\n起こす要件",
    lead: "机上の機能一覧ではなく、現場で実際にたどっている手順を写し取ってから要件へ落とします。使われない画面を作らないための最初の一手です。",
    facts: [
      { label: "現場同行", value: "3回" },
      { label: "要件の確定まで", value: "14日" },
    ],
    figure: "trace",
    caption: "図: 手順の経路と、判断が起きる三つの地点",
  },
  {
    no: "02",
    nav: "決める",
    kind: "設計・合意",
    heading: "迷いを残さない\n判断の順序",
    lead: "決める順番と、決める人を先に固定します。後段の設計が前段の結論を掘り返さないので、合意のやり直しが起きません。",
    facts: [
      { label: "決裁の待ち時間", value: "-62%" },
      { label: "差し戻し", value: "0.4回" },
    ],
    figure: "cell",
    caption: "図: 全体のうち、この工程で確定させる範囲",
  },
  {
    no: "03",
    nav: "続ける",
    kind: "運用移管",
    heading: "引き渡したあとも\n読める設計",
    lead: "更新の手順を外側から内側へ、順に開いていきます。引き渡しの日を境に手が止まらないよう、運用の担い手ごと設計します。",
    facts: [
      { label: "運用移管", value: "14日" },
      { label: "更新の内製率", value: "87%" },
    ],
    figure: "nest",
    caption: "図: 外側から順に開いていく更新の担当範囲",
  },
];

/** 上に貼り付いたヘッダーの高さ。ヒーローの天井はそのぶん下がる(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
  );
}

/** 図版。画像素材を持たず、hairline の方眼+アンバーの図形だけで作る(面ごとに作図が違う) */
function Figure({ kind }: { kind: Frame["figure"] }) {
  return (
    <div
      aria-hidden="true"
      className="relative aspect-[5/4] w-full border border-gray-200 bg-[#faf7f2] sm:aspect-[4/3] lg:aspect-auto lg:h-[clamp(260px,44svh,460px)]"
    >
      <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%)",
        }}
      />
      {kind === "trace" && (
        <>
          <div className="absolute top-[74%] left-[10%] h-px w-[52%] origin-left -rotate-[28deg] bg-amber-600" />
          <div className="absolute top-[38%] left-[56%] h-px w-[32%] origin-left rotate-[18deg] bg-amber-600" />
          <span className="absolute top-[70%] left-[9%] h-2 w-2 -translate-y-1/2 bg-amber-600" />
          <span className="absolute top-[36%] left-[55%] h-2 w-2 -translate-y-1/2 bg-amber-600" />
          <span className="absolute top-[47%] left-[87%] h-2 w-2 -translate-y-1/2 border border-amber-600 bg-white" />
        </>
      )}
      {kind === "cell" && (
        <>
          <div className="absolute top-1/4 left-[20%] h-1/4 w-1/5 bg-amber-600" />
          <div className="absolute top-1/2 left-[40%] h-1/4 w-1/5 border border-amber-600" />
          <div className="absolute top-1/4 left-[60%] h-1/2 w-px bg-gray-900/30" />
        </>
      )}
      {kind === "nest" && (
        <>
          <div className="absolute inset-[9%] border border-gray-300" />
          <div className="absolute inset-[21%] border border-gray-900/30" />
          <div className="absolute inset-[33%] border border-amber-600" />
          <span className="absolute top-1/2 left-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 bg-amber-600" />
        </>
      )}
    </div>
  );
}

export default function ExhibitRailHero({
  headingTag: Heading = "h1",
}: {
  /**
   * 見出しのタグ。実案件ではそのまま(h1)。ギャラリーのプレビューは1ページに複数の標本が
   * 並ぶため "p" を渡して h1 の重複を避ける(見た目は className で決まるので変わらない)。
   */
  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);
  const [active, setActive] = useState(0);
  const [mounted, setMounted] = useState(false);
  const total = frames.length;

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

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

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

      const ctx = gsap.context(() => {
        // 面の到着点(=止まって読ませる位置)の時刻。索引クリックの逆算にも使う
        const labelTimes: number[] = [0];

        // 読み取りは ScrollTrigger の onUpdate ではなくタイムライン側の onUpdate で行う。
        // scrub はスクロールが止まったあとも補間を続けるが、ScrollTrigger の onUpdate は
        // スクロール位置が変わったときしか呼ばれないので、止めた瞬間の値で固まってしまう。
        const onTick = () => {
          section.style.setProperty("--xr-p", tl.totalProgress().toFixed(4));
          const t = tl.time();
          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;
            }
          }
          setActive((prev) => (prev === nearest ? prev : nearest));
        };

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

        for (let i = 1; i < total; i += 1) {
          // 移動: 次の面へ寄せる。レール幅は total 面ぶんなので、1面 = 100/total %
          tl.to(rail, {
            xPercent: -(100 / total) * i,
            duration: 1,
            ease: "power2.inOut",
          });
          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: 0.5, stagger: 0.09, ease: "power2.out" },
              "-=0.42",
            );
          }
          // 静止: ここを進んでも画面は動かない=読ませる間
          tl.to({}, { duration: 0.6 });
        }

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

        jumpRef.current = (i: number) => {
          const duration = tl.duration() || 1;
          const top = st.start + (st.end - st.start) * (labelTimes[i] / duration);
          window.scrollTo({ top, behavior: "smooth" });
        };
      }, section);

      dispose = () => {
        jumpRef.current = null;
        ctx.revert();
      };
    })();

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

  const jumpTo = useCallback((i: number) => {
    if (jumpRef.current) {
      jumpRef.current(i);
      return;
    }
    // 平置き(reduced-motion・埋め込み)では面そのものを縦に送る
    frameRefs.current[i]?.scrollIntoView({
      behavior: prefersReducedMotion() ? "auto" : "smooth",
      block: "center",
    });
    setActive(i);
  }, []);

  const current = frames[active] ?? frames[0];
  /** 表紙だけのリビール(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(" ");

  return (
    <section ref={sectionRef} aria-labelledby="xr-title" className="xr w-full bg-white">
      <style>{`
        .xr-bar { transform: scaleX(var(--xr-p, 0)); }
        /* 平置きへの降格: 固定も横送りもやめ、面を縦に積んだ読み物にする(マークアップは同じ) */
        .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 { transform: none; }
        @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 { transform: none; }
        }
      `}</style>

      <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-4 px-5 pt-5 sm:px-8 sm:pt-7">
          <p className="hidden min-w-0 items-center gap-3 sm:flex">
            <span aria-hidden="true" className="h-9 w-px shrink-0 bg-amber-600" />
            <span className="min-w-0 text-[11px] leading-tight font-bold whitespace-nowrap text-gray-900 sm:text-xs">
              事業案内
              <span className="mt-0.5 hidden text-[10px] font-medium text-gray-500 sm:block">
                四面で読む仕事の順序
              </span>
            </span>
          </p>

          <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}
                  className="flex min-h-11 w-11 flex-col items-start justify-start gap-1 px-1 pt-1 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 sm:w-[3.75rem]"
                >
                  <span
                    className={`${montserrat.className} text-[11px] font-bold tabular-nums transition-colors ${
                      isActive ? "text-amber-600" : "text-gray-400"
                    }`}
                  >
                    {frame.no}
                  </span>
                  <span
                    className={`text-[10px] leading-none font-medium transition-colors ${
                      isActive ? "text-gray-900" : "text-gray-400"
                    }`}
                  >
                    {frame.nav}
                  </span>
                  <span
                    aria-hidden="true"
                    className={`mt-0.5 h-px w-full transition-colors ${
                      isActive ? "bg-amber-600" : "bg-gray-200"
                    }`}
                  />
                </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-20 pb-24 sm:px-8 sm:pt-24 sm:pb-28 lg:px-16"
              // 面そのものを寸法の基準にする(cqw)。ビューポート基準の vw だと、枠のある入れ物へ
              // 埋め込んだとき見出しだけが入れ物より大きく算出されて折り返す
              style={{ width: `${100 / total}%`, containerType: "inline-size" }}
            >
              {frame.figure === "cover" ? (
                <div className="mx-auto w-full max-w-[1120px]">
                  <p
                    className={`flex items-center gap-2 text-[11px] font-bold text-amber-600 sm:text-xs ${coverStep(0)}`}
                  >
                    <span aria-hidden="true" className="h-px w-6 bg-amber-600" />
                    {frame.kind}
                  </p>
                  <Heading
                    id="xr-title"
                    className={`mt-5 text-[clamp(1.55rem,8.2cqw,4.25rem)] leading-[1.18] font-bold tracking-tight text-gray-900 ${coverStep(1)}`}
                  >
                    {frame.heading.split("\n").map((line) => (
                      <span key={line} className="block">
                        {line}
                      </span>
                    ))}
                  </Heading>
                  <p
                    className={`mt-6 max-w-[42ch] text-[14px] leading-[1.95] font-medium text-gray-700 sm:text-[16px] ${coverStep(2)}`}
                  >
                    {frame.lead}
                  </p>
                  <div className={`mt-9 flex flex-wrap items-center gap-x-7 gap-y-4 ${coverStep(3)}`}>
                    <a
                      href="#xr-title"
                      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 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                    >
                      取り組み方を見る
                    </a>
                    <a
                      href="#xr-title"
                      className="inline-flex min-h-11 items-center text-sm font-bold text-gray-900 underline decoration-gray-300 decoration-1 underline-offset-[6px] transition-colors hover:decoration-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                    >
                      会社案内を読む
                    </a>
                  </div>

                  {/* 表紙に置く目次。この先に何面あるかを先に見せ、押すとその面へ送る
                      (表紙は常に画面内なので、フォーカスが画面外の面へ飛ぶ心配がない) */}
                  <ul
                    className={`mt-11 border-t border-gray-200 ${coverStep(3)}`}
                    aria-label="この先の面"
                  >
                    {frames.slice(1).map((frame, i) => (
                      <li key={frame.no} className="border-b border-gray-200">
                        <button
                          type="button"
                          onClick={() => jumpTo(i + 1)}
                          className="group flex w-full items-baseline gap-4 py-3 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 sm:gap-6"
                        >
                          <span
                            className={`${montserrat.className} shrink-0 text-[11px] font-bold tabular-nums text-amber-600`}
                          >
                            {frame.no}
                          </span>
                          <span className="shrink-0 text-[12px] font-bold text-gray-900 transition-colors group-hover:text-amber-700 sm:text-[13px]">
                            {frame.nav}
                          </span>
                          <span className="min-w-0 truncate text-[11px] font-medium text-gray-500 sm:text-xs">
                            {frame.heading.replace("\n", "")}
                          </span>
                        </button>
                      </li>
                    ))}
                  </ul>
                </div>
              ) : (
                <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-7 lg:flex-row lg:items-center lg:gap-14">
                  <div className="lg:w-[54%]">
                    <p
                      data-reveal
                      className="flex items-center gap-2 text-[11px] font-bold text-amber-600 sm:text-xs"
                    >
                      <span aria-hidden="true" className="h-px w-6 bg-amber-600" />
                      {frame.kind}
                    </p>
                    <h2
                      data-reveal
                      className="mt-4 text-[clamp(1.5rem,5.6cqw,2.9rem)] leading-[1.3] font-bold tracking-tight text-gray-900"
                    >
                      {frame.heading.split("\n").map((line) => (
                        <span key={line} className="block">
                          {line}
                        </span>
                      ))}
                    </h2>
                    <p
                      data-reveal
                      className="mt-4 max-w-[44ch] text-[13.5px] leading-[1.95] font-medium text-gray-700 sm:text-[15px]"
                    >
                      {frame.lead}
                    </p>
                    <dl
                      data-reveal
                      className="mt-7 grid max-w-md grid-cols-2 gap-x-8 border-t border-gray-200 pt-4"
                    >
                      {frame.facts.map((fact) => (
                        <div key={fact.label}>
                          <dt className="text-[11px] font-medium text-gray-500">{fact.label}</dt>
                          <dd
                            className={`${montserrat.className} mt-1 text-[clamp(1.35rem,3.4cqw,2rem)] leading-none font-extrabold tabular-nums text-gray-900`}
                          >
                            {fact.value}
                          </dd>
                        </div>
                      ))}
                    </dl>
                  </div>

                  <div data-reveal className="lg:w-[46%]">
                    <Figure kind={frame.figure} />
                    {frame.caption && (
                      <p className="mt-2.5 text-[10px] leading-relaxed font-medium text-gray-500">
                        {frame.caption}
                      </p>
                    )}
                  </div>
                </div>
              )}
            </div>
          ))}
        </div>

        {/* 下帯: いま読んでいる面の種別と、送りの進み具合 */}
        <div className="xr-band pointer-events-none absolute inset-x-0 bottom-0 z-20 px-5 pb-5 sm:px-8 sm:pb-7">
          <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-3 flex items-end justify-between gap-4">
            <p className="text-[11px] font-medium text-gray-500 sm:text-xs">{current.kind}</p>
            <p className={`${montserrat.className} text-[11px] font-bold tabular-nums text-gray-900`}>
              {current.no}
              <span className="text-gray-400"> / {String(total - 1).padStart(2, "0")}</span>
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

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