BLK-89Blocks

Page-Flip Digital Brochure

An eight-page company brochure read as two-page spreads: a digital flipbook. Each sheet is a leaf standing on a CSS perspective / preserve-3d stage. Pinch the bottom-right corner and pull, and the page turns at the angle that keeps its edge under your finger; stop moving and it waits at that angle. Release past the halfway point and it completes the turn, otherwise it falls back. Stacking order comes from a per-leaf translateZ rather than z-index, so jumping several pages ahead from the page list turns multiple sheets in a staggered sequence without glitches. In a narrow container it switches to a single-page view where turned sheets swing out of the frame. Arrow keys, Page Up/Down, Home/End, previous/next buttons and the page list make it fully keyboard operable, and faces that are not on screen are inert so assistive tech only reads the current spread. Page text is set in cqi units that scale with the page width, so stats, a small trend bar chart, a price table and a before/after case all stay readable inside the pages. With prefers-reduced-motion the turn is skipped and pages swap instantly as still, readable paper. No 3D library and no image assets; no small English kickers, trailing arrow links, rounded corners or scroll-in effects. All contents are samples.

Added:
2026-10-04
Dependencies:
None
tags
#flipbook #page-flip #brochure #css-3d #drag #keyboard #data-visualization #container-query #interactive #no-image

Preview

めくって読む会社案内

右下の角をつまんで引くと紙がめくれ、指を止めればその角度で待ちます。矢印キーと下のページ一覧からも移動できます。

全
8ページ
表示
2面
判型
A4縦

左右の矢印キーでページをめくります。Home で表紙、End で裏表紙へ移ります。

会社案内2026

小さな会社の
ウェブを
長く育てる

制作から公開後の運用まで、ひとつの窓口で続けて見ます。

ウェブ制作・運用支援全8ページ・見本

表紙/ 08

"use client";

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import { Montserrat } from "next/font/google";

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

/**
 * ページをめくるデジタルパンフレット
 * 既存の資料DL CTA(BLK-47)は表紙を立体で見せて交換させる型、3D標本(BLK-86/88)は物体を回す・切る型。
 * 本ブロックは8ページの中身そのものを見開き単位で読ませる「めくる」操作が主役の読み物ビューアにした。
 * 英字キッカー・末尾の矢印リンク・角丸・スクロール出現は使わず、hairline 罫線と余白で組んでいる。
 *
 * 汎用技法メモ:
 * - 紙は「葉(leaf)」で持つ。見開きでは葉 i の表=ページ 2i、裏=ページ 2i+1。めくった枚数 t だけで
 *   全ページの位置が決まり(i < t の葉は rotateY(-180deg))、静止した下敷きページは要らない。
 * - 重なり順は z-index で管理しない。本体を preserve-3d にし、葉ごとに translateZ(-i×0.5px) を
 *   回転の後ろへ掛けると、めくる前は若い葉が手前・めくった後(Z が反転)は新しい葉が手前になり、
 *   めくり途中の葉は持ち上がった分だけ自然に前へ出る。複数枚を時差でめくっても破綻しない。
 * - 角のドラッグは「紙の端が指に付いてくる」角度へ写す: cosθ = (指のx − 綴じ目) ÷ ページ幅。
 *   離した時点で -90° を越えていればめくり切り、越えていなければ戻す(ドラッグ中は transition を切る)。
 * - 1ページ表示では綴じ目を枠の左端に置き、めくった葉は枠の外へ出す。枠は overflow-x: clip で切る
 *   (hidden と違い縦方向の持ち上がりを切らない)。見開き⇔1ページは ResizeObserver で器の幅から決める。
 * - 見えていない面は inert + aria-hidden にし、読み上げとフォーカスを今の見開きだけに絞る。
 * - prefers-reduced-motion では transition を外して即座に差し替える(ドラッグ自体は指に追従させる)。
 *
 * 掲載内容(会社案内の文面・数字・料金・連絡先)はすべて表示サンプル。
 */

type PageDef = { title: string; body: ReactNode };

const STATS = [
  { label: "支援した会社", value: "126", unit: "社" },
  { label: "平均の運用年数", value: "4.2", unit: "年" },
  { label: "公開後の改善", value: "月2", unit: "回" },
  { label: "契約の継続率", value: "92", unit: "%" },
];

/** 問い合わせ数の推移(初年度を100とした指数) */
const TREND = [
  { year: "22", v: 100 },
  { year: "23", v: 128 },
  { year: "24", v: 151 },
  { year: "25", v: 183 },
  { year: "26", v: 214 },
];

const SERVICES = [
  { no: "01", name: "設計と制作", text: "目的と読み手を先に決め、必要なページだけを作ります。" },
  { no: "02", name: "公開後の運用", text: "数字を毎月見て、文言と導線を少しずつ直します。" },
  { no: "03", name: "社内への引き継ぎ", text: "更新の手順書を残し、自社で回せる形にします。" },
];

const STEPS = [
  { week: "1週", name: "ヒアリング" },
  { week: "2週", name: "構成と原稿" },
  { week: "3週", name: "デザイン" },
  { week: "2週", name: "実装と確認" },
  { week: "毎月", name: "改善の打ち合わせ" },
];

const PRICES = [
  { name: "小さく始める", note: "5ページまで", price: "30万円〜" },
  { name: "標準", note: "12ページまで", price: "60万円〜" },
  { name: "運用", note: "月次の改善", price: "月3万円〜" },
  { name: "撮影・取材", note: "半日から", price: "別途" },
];

const label = "text-[2.9cqi] font-bold tracking-[0.12em] text-amber-700";
const heading = "mt-[2cqi] text-[6.2cqi] leading-[1.35] font-bold tracking-tight text-gray-900";
const bodyText = "text-[3.5cqi] leading-[1.8] text-gray-600";
const num = `${montserrat.className} tabular-nums`;

const PAGES: PageDef[] = [
  {
    title: "表紙",
    body: (
      <div className="flex h-full flex-col">
        <div className="flex items-center justify-between border-b border-gray-900/80 pb-[2.5cqi]">
          <span className="text-[3cqi] font-bold tracking-[0.14em] text-gray-900">会社案内</span>
          <span className={`${num} text-[3cqi] font-semibold text-gray-500`}>2026</span>
        </div>
        <div className="mt-[14cqi] h-[1.4cqi] w-[16cqi] bg-amber-600" aria-hidden="true" />
        <p className="mt-[5cqi] text-[8.6cqi] leading-[1.32] font-bold tracking-tight text-gray-900">
          小さな会社の<br />ウェブを<br />長く育てる
        </p>
        <p className={`mt-[5cqi] ${bodyText}`}>制作から公開後の運用まで、ひとつの窓口で続けて見ます。</p>
        <div className="mt-auto grid grid-cols-2 border-t border-gray-200 pt-[3cqi] text-[2.9cqi] text-gray-500">
          <span>ウェブ制作・運用支援</span>
          <span className="text-right">全8ページ・見本</span>
        </div>
      </div>
    ),
  },
  {
    title: "はじめに",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>はじめに</p>
        <p className={heading}>作って終わりに<br />しないために</p>
        <p className={`mt-[5cqi] ${bodyText}`}>ウェブサイトは公開した日がいちばん未完成です。問い合わせの数、読まれている箇所、途中で離れる場所。数字を見ながら直し続けると、同じ予算でも結果が変わっていきます。</p>
        <p className={`mt-[3cqi] ${bodyText}`}>この冊子では、私たちの進め方と費用の目安を8ページにまとめました。</p>
        <div className="mt-auto border-t border-gray-200 pt-[3cqi] text-[3cqi] text-gray-500">代表(見本)</div>
      </div>
    ),
  },
  {
    title: "数字で見る",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>数字で見る</p>
        <dl className="mt-[4cqi] grid grid-cols-2 border-t border-l border-gray-200">
          {STATS.map((s) => (
            <div key={s.label} className="border-r border-b border-gray-200 px-[3cqi] py-[2.6cqi]">
              <dt className="text-[2.8cqi] text-gray-500">{s.label}</dt>
              <dd className="mt-[1cqi] text-gray-900">
                <span className={`${num} text-[7cqi] leading-none font-bold`}>{s.value}</span>
                <span className="ml-[0.8cqi] text-[3cqi]">{s.unit}</span>
              </dd>
            </div>
          ))}
        </dl>
        <p className="mt-[5cqi] text-[2.9cqi] font-bold text-gray-900">問い合わせ数の推移(2022年=100)</p>
        <div className="mt-[2.5cqi] flex flex-1 items-end gap-[3cqi] border-b border-gray-300 pb-px" aria-hidden="true">
          {TREND.map((t, i) => (
            <div key={t.year} className="flex h-full flex-1 flex-col justify-end">
              <span className={`${num} mb-[1cqi] text-center text-[2.6cqi] text-gray-500`}>{t.v}</span>
              <div className={i === TREND.length - 1 ? "bg-amber-600" : "bg-gray-300"} style={{ height: `${(t.v / 214) * 70}%` }} />
            </div>
          ))}
        </div>
        <div className="mt-[1.2cqi] flex gap-[3cqi]" aria-hidden="true">
          {TREND.map((t) => (
            <span key={t.year} className={`${num} flex-1 text-center text-[2.6cqi] text-gray-400`}>&apos;{t.year}</span>
          ))}
        </div>
        <p className="sr-only">{TREND.map((t) => `20${t.year}年 ${t.v}`).join("、")}</p>
      </div>
    ),
  },
  {
    title: "できること",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>できること</p>
        <p className={heading}>3つの仕事を<br />ひと続きで</p>
        <ol className="mt-[5cqi] border-t border-gray-200">
          {SERVICES.map((s) => (
            <li key={s.no} className="grid grid-cols-[9cqi_1fr] border-b border-gray-200 py-[3.4cqi]">
              <span className={`${num} text-[3.4cqi] font-semibold text-amber-600`}>{s.no}</span>
              <span>
                <span className="block text-[4cqi] font-bold text-gray-900">{s.name}</span>
                <span className={`mt-[1cqi] block ${bodyText}`}>{s.text}</span>
              </span>
            </li>
          ))}
        </ol>
      </div>
    ),
  },
  {
    title: "進め方",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>進め方</p>
        <p className={heading}>公開まで<br />およそ8週間</p>
        <ol className="relative mt-[6cqi] ml-[1.2cqi] border-l border-gray-300">
          {STEPS.map((s, i) => (
            <li key={s.name} className="relative flex items-baseline justify-between gap-[3cqi] pb-[4.6cqi] pl-[5cqi] last:pb-0">
              <span className={`absolute top-[1.5cqi] -left-[1.2cqi] size-[2.4cqi] ${i === STEPS.length - 1 ? "bg-amber-600" : "border border-gray-400 bg-white"}`} aria-hidden="true" />
              <span className="text-[3.8cqi] font-bold text-gray-900">{s.name}</span>
              <span className={`${num} shrink-0 text-[3cqi] text-gray-500`}>{s.week}</span>
            </li>
          ))}
        </ol>
        <p className={`mt-auto border-t border-gray-200 pt-[3cqi] text-[3cqi] leading-[1.7] text-gray-500`}>期間はページ数と原稿の準備状況で前後します。</p>
      </div>
    ),
  },
  {
    title: "料金の目安",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>料金の目安</p>
        <p className={heading}>必要な分だけを<br />組み合わせて</p>
        <table className="mt-[5cqi] w-full border-t border-gray-900/80 text-left">
          <caption className="sr-only">料金の目安(税別・見本)</caption>
          <tbody>
            {PRICES.map((p) => (
              <tr key={p.name} className="border-b border-gray-200">
                <th scope="row" className="py-[3cqi] align-baseline font-normal">
                  <span className="block text-[3.8cqi] font-bold text-gray-900">{p.name}</span>
                  <span className="block text-[2.9cqi] text-gray-500">{p.note}</span>
                </th>
                <td className={`${num} py-[3cqi] text-right align-baseline text-[3.8cqi] font-semibold text-gray-900`}>{p.price}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <p className="mt-auto pt-[3cqi] text-[2.9cqi] text-gray-500">金額は税別の見本です。</p>
      </div>
    ),
  },
  {
    title: "事例",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>事例</p>
        <p className={heading}>地域の工務店<br />(見本)</p>
        <p className={`mt-[4cqi] ${bodyText}`}>施工写真の並べ方と、相談までの導線を見直しました。原稿は社員の方と一緒に書いています。</p>
        <p className="mt-[5cqi] text-[2.9cqi] font-bold text-gray-900">月あたりの問い合わせ</p>
        <div className="mt-[2.5cqi] space-y-[2.4cqi]">
          {[
            { k: "見直し前", v: 3, cls: "bg-gray-300" },
            { k: "6か月後", v: 11, cls: "bg-amber-600" },
          ].map((r) => (
            <div key={r.k} className="grid grid-cols-[14cqi_1fr_8cqi] items-center gap-[2cqi]">
              <span className="text-[2.9cqi] text-gray-500">{r.k}</span>
              <span className="h-[3cqi] bg-gray-100">
                <span className={`block h-full ${r.cls}`} style={{ width: `${(r.v / 11) * 100}%` }} />
              </span>
              <span className={`${num} text-right text-[3.6cqi] font-bold text-gray-900`}>{r.v}件</span>
            </div>
          ))}
        </div>
        <p className="mt-auto border-t border-gray-200 pt-[3cqi] text-[2.9cqi] text-gray-500">数字は説明のための見本です。</p>
      </div>
    ),
  },
  {
    title: "お問い合わせ",
    body: (
      <div className="flex h-full flex-col">
        <p className={label}>お問い合わせ</p>
        <p className={heading}>まずは30分<br />お話を聞かせて<br />ください</p>
        <dl className="mt-[6cqi] border-t border-gray-200 text-[3.3cqi]">
          {[
            ["受付", "平日 10:00–18:00"],
            ["メール", "hello@example.com"],
            ["面談", "オンライン・来社"],
          ].map(([k, v]) => (
            <div key={k} className="grid grid-cols-[13cqi_1fr] border-b border-gray-200 py-[2.6cqi]">
              <dt className="text-gray-500">{k}</dt>
              <dd className="font-semibold break-all text-gray-900">{v}</dd>
            </div>
          ))}
        </dl>
        <div className="mt-auto flex items-end justify-between border-t border-gray-900/80 pt-[3cqi]">
          <span className="text-[2.9cqi] text-gray-500">内容はすべて見本です</span>
          <span className="h-[1.4cqi] w-[10cqi] bg-amber-600" aria-hidden="true" />
        </div>
      </div>
    ),
  },
];

const TOTAL = PAGES.length;
/** 見開きへ切り替える器の幅(px) */
const SPREAD_MIN = 600;
/** 複数枚を続けてめくるときの時差(ms) */
const STAGGER = 110;

const pad = (n: number) => String(n).padStart(2, "0");

function subscribeReduced(cb: () => void) {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
  mq.addEventListener("change", cb);
  return () => mq.removeEventListener("change", cb);
}
const readReduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/** めくった枚数(見開きは葉=2ページ、1ページ表示は葉=1ページ) */
const turnedOf = (page: number, spread: boolean) => (spread ? Math.ceil(page / 2) : page);
/** めくった枚数から代表ページ(見開きなら左ページ)を返す */
const pageOf = (turned: number, spread: boolean) => (spread ? Math.max(0, 2 * turned - 1) : turned);

type Drag = { leaf: number; angle: number; startX: number; moved: boolean; dir: "next" | "prev" };

function Face({ page, side, spread, hidden }: { page: number | null; side: "front" | "back"; spread: boolean; hidden: boolean }) {
  const isBack = side === "back";
  // 見開きの左ページ(葉の裏)は綴じ目が右、右ページ(葉の表)は綴じ目が左
  const gutter = isBack
    ? "bg-[linear-gradient(to_left,rgba(17,17,17,0.10),transparent_9%)]"
    : "bg-[linear-gradient(to_right,rgba(17,17,17,0.10),transparent_9%)]";
  const numberLeft = spread && page !== null && page % 2 === 1;
  return (
    <div
      className="@container absolute inset-0 overflow-hidden border border-gray-200 bg-[#fffdf9] [backface-visibility:hidden]"
      style={isBack ? { transform: "rotateY(180deg)" } : undefined}
      aria-hidden={hidden || page === null ? true : undefined}
      inert={hidden || page === null}
      role={page === null ? undefined : "group"}
      aria-label={page === null ? undefined : `${page + 1}ページ ${PAGES[page].title}`}
    >
      {page === null ? (
        <div className="h-full bg-[repeating-linear-gradient(180deg,transparent_0_11px,rgba(17,17,17,0.025)_11px_12px)]" />
      ) : (
        <div className="flex h-full flex-col px-[7cqi] pt-[7cqi] pb-[4.5cqi]">
          <div className="min-h-0 flex-1">{PAGES[page].body}</div>
          {page !== 0 && page !== TOTAL - 1 && (
            <p className={`${num} mt-[3cqi] text-[2.7cqi] text-gray-400 ${numberLeft ? "text-left" : "text-right"}`}>{pad(page + 1)}</p>
          )}
        </div>
      )}
      <div className={`pointer-events-none absolute inset-0 ${spread || !isBack ? gutter : ""}`} aria-hidden="true" />
    </div>
  );
}

export default function PageFlipBrochure() {
  const sectionRef = useRef<HTMLElement>(null);
  const overlayRef = useRef<HTMLDivElement>(null);
  const [spread, setSpread] = useState(true);
  const [page, setPage] = useState(0);
  const [delays, setDelays] = useState<Record<number, number>>({});
  const [drag, setDrag] = useState<Drag | null>(null);
  const [peek, setPeek] = useState<"next" | "prev" | null>(null);
  const reduced = useSyncExternalStore(subscribeReduced, readReduced, () => false);

  useEffect(() => {
    const el = sectionRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setSpread(entry.contentRect.width >= SPREAD_MIN));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const leaves = spread ? TOTAL / 2 : TOTAL;
  const turned = turnedOf(page, spread);

  const goTo = useCallback(
    (target: number) => {
      const next = Math.max(0, Math.min(TOTAL - 1, target));
      const from = turnedOf(page, spread);
      const to = turnedOf(next, spread);
      const d: Record<number, number> = {};
      if (to > from) for (let i = from; i < to; i++) d[i] = (i - from) * STAGGER;
      else for (let i = from - 1; i >= to; i--) d[i] = (from - 1 - i) * STAGGER;
      setDelays(d);
      setPage(next);
    },
    [page, spread],
  );

  const goNext = () => goTo(pageOf(turned + 1, spread));
  const goPrev = () => goTo(pageOf(turned - 1, spread));

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const map: Record<string, () => void> = {
      ArrowRight: goNext,
      ArrowDown: goNext,
      PageDown: goNext,
      ArrowLeft: goPrev,
      ArrowUp: goPrev,
      PageUp: goPrev,
      Home: () => goTo(0),
      End: () => goTo(TOTAL - 1),
    };
    const fn = map[e.key];
    if (!fn) return;
    e.preventDefault();
    fn();
  };

  /** 指の位置から葉の角度(0〜-180)を求める。紙の端が指に付いてくるように写す */
  const angleAt = (clientX: number) => {
    const rect = overlayRef.current?.getBoundingClientRect();
    if (!rect) return 0;
    // 見開きは綴じ目=中央。1ページ表示も中央を仮の綴じ目にして、枠の左端で -180° に届かせる
    const spine = rect.left + rect.width / 2;
    const half = rect.width / 2;
    const c = Math.max(-1, Math.min(1, (clientX - spine) / half));
    return -(Math.acos(c) * 180) / Math.PI;
  };

  const onDown = (dir: "next" | "prev") => (e: ReactPointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return;
    const leaf = dir === "next" ? turned : turned - 1;
    if (leaf < 0 || leaf >= leaves) return;
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setDelays({});
    setPeek(null);
    setDrag({ leaf, angle: dir === "next" ? 0 : -180, startX: e.clientX, moved: false, dir });
  };

  const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!drag) return;
    const moved = drag.moved || Math.abs(e.clientX - drag.startX) > 4;
    if (!moved) return;
    setDrag({ ...drag, moved, angle: angleAt(e.clientX) });
  };

  const onUp = () => {
    if (!drag) return;
    const done = drag.moved ? drag.angle < -90 : drag.dir === "next";
    setDrag(null);
    setPage(pageOf(done ? drag.leaf + 1 : drag.leaf, spread));
  };

  const visible = new Set(spread ? [2 * turned - 1, 2 * turned] : [page]);
  const shift = spread ? (turned === 0 ? "-25%" : turned === leaves ? "25%" : "0%") : "0%";
  const motion = reduced ? "" : "transition-transform duration-700 ease-[cubic-bezier(0.45,0.05,0.25,1)]";

  const readout = spread
    ? turned === 0
      ? "表紙"
      : turned === leaves
        ? "裏表紙"
        : `${pad(2 * turned)}–${pad(2 * turned + 1)}`
    : pad(page + 1);
  const progress = spread ? turned / leaves : page / (TOTAL - 1);
  const canPrev = turned > 0;
  const canNext = turned < leaves;

  const btn =
    "inline-flex items-center gap-2 border border-gray-300 bg-white px-3.5 py-2 text-[13px] font-bold whitespace-nowrap text-gray-900 transition-colors hover:border-amber-600 hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-300 motion-reduce:transition-none";

  return (
    <section ref={sectionRef} aria-labelledby="pfb-title" className="w-full max-w-5xl bg-white text-gray-900">
      <div className="flex flex-col gap-5 border-b border-gray-200 pb-6 sm:flex-row sm:items-end sm:justify-between">
        <div>
          <h2 id="pfb-title" className="text-[clamp(1.3rem,3.2vw,1.9rem)] leading-[1.4] font-bold tracking-tight">
            めくって読む会社案内
          </h2>
          <p className="mt-2 max-w-xl text-[13px] leading-[1.9] text-gray-600">右下の角をつまんで引くと紙がめくれ、指を止めればその角度で待ちます。矢印キーと下のページ一覧からも移動できます。</p>
        </div>
        <dl className="grid shrink-0 grid-cols-3 border-y border-gray-200 text-[11px] sm:w-[17rem]">
          {[
            ["全", `${TOTAL}`, "ページ"],
            ["表示", spread ? "2" : "1", "面"],
            ["判型", "A4", "縦"],
          ].map(([k, v, u], i) => (
            <div key={k} className={`px-3 py-2 ${i > 0 ? "border-l border-gray-200" : ""}`}>
              <dt className="text-gray-500">{k}</dt>
              <dd className="mt-0.5 whitespace-nowrap text-gray-900">
                <span className={`${num} text-[1.15rem] font-bold`}>{v}</span>
                <span className="ml-0.5">{u}</span>
              </dd>
            </div>
          ))}
        </dl>
      </div>

      <div className="py-8 sm:py-10">
        <div
          role="group"
          aria-roledescription="パンフレット"
          aria-label={`会社案内 全${TOTAL}ページ`}
          aria-describedby="pfb-help"
          tabIndex={0}
          onKeyDown={onKeyDown}
          className={`relative mx-auto w-full outline-none focus-visible:outline-2 focus-visible:outline-offset-8 focus-visible:outline-amber-600 ${
            spread ? "aspect-[3/2] max-w-[44rem]" : "aspect-[3/4] max-w-[22rem] overflow-x-clip"
          }`}
          style={{ perspective: spread ? "2200px" : "1600px" }}
        >
          <p id="pfb-help" className="sr-only">
            左右の矢印キーでページをめくります。Home で表紙、End で裏表紙へ移ります。
          </p>
          {/* 接地影(平面に置く) */}
          <div
            aria-hidden="true"
            className={`pointer-events-none absolute -bottom-3 left-1/2 h-4 -translate-x-1/2 bg-gray-900/15 blur-md ${spread && (turned === 0 || turned === leaves) ? "w-[46%]" : "w-[92%]"}`}
          />
          <div
            className={`absolute inset-0 [transform-style:preserve-3d] ${motion}`}
            style={{ transform: `translateX(${shift})` }}
          >
            {Array.from({ length: leaves }, (_, i) => {
              const base = i < turned ? -180 : 0;
              let angle = base;
              if (drag?.leaf === i) angle = drag.angle;
              else if (!reduced && !drag && peek === "next" && i === turned) angle = -12;
              else if (!reduced && !drag && peek === "prev" && i === turned - 1) angle = -168;
              const front = spread ? 2 * i : i;
              const back = spread ? 2 * i + 1 : null;
              const shade = Math.sin((-angle * Math.PI) / 180);
              const dragging = drag?.leaf === i;
              return (
                <div
                  key={`${spread ? "s" : "p"}${i}`}
                  className={`absolute top-0 bottom-0 origin-left [transform-style:preserve-3d] ${spread ? "left-1/2 w-1/2" : "left-0 w-full"} ${dragging ? "" : motion}`}
                  style={{
                    transform: `rotateY(${angle}deg) translateZ(${-i * 0.5}px)`,
                    transitionDelay: dragging ? undefined : `${delays[i] ?? 0}ms`,
                  }}
                >
                  <Face page={front} side="front" spread={spread} hidden={!visible.has(front)} />
                  <Face page={back} side="back" spread={spread} hidden={back === null || !visible.has(back)} />
                  {/* めくり途中の陰り。持ち上がるほど濃くなる */}
                  <div
                    aria-hidden="true"
                    className={`pointer-events-none absolute inset-0 bg-[linear-gradient(to_left,rgba(17,17,17,0.22),rgba(17,17,17,0.04)_60%)] [backface-visibility:hidden] ${dragging || reduced ? "" : "transition-opacity duration-700"}`}
                    style={{ opacity: shade * 0.9, transitionDelay: dragging ? undefined : `${delays[i] ?? 0}ms` }}
                  />
                </div>
              );
            })}
          </div>

          {/* 角のつまみ(ポインタ専用。キーボードは矢印キーとボタンで同じ操作ができる) */}
          <div
            ref={overlayRef}
            aria-hidden="true"
            className={`pointer-events-none absolute inset-0 ${motion}`}
            style={{ transform: `translateX(${shift})` }}
          >
            {canNext && (
              <div
                onPointerDown={onDown("next")}
                onPointerMove={onMove}
                onPointerUp={onUp}
                onPointerCancel={onUp}
                onPointerEnter={(e) => {
                  if (e.pointerType === "mouse") setPeek("next");
                }}
                onPointerLeave={() => setPeek(null)}
                className={`group pointer-events-auto absolute right-0 bottom-0 h-[26%] cursor-grab touch-none select-none active:cursor-grabbing ${spread ? "w-[13%]" : "w-[26%]"}`}
              >
                <span
                  className={`absolute right-0 bottom-0 size-5 bg-[linear-gradient(315deg,transparent_50%,#ece7de_50%,#f8f5ef_72%)] shadow-[-2px_-2px_4px_-2px_rgba(17,17,17,0.25)] transition-opacity duration-300 motion-reduce:transition-none ${drag || peek ? "opacity-0" : "opacity-100"}`}
                />
              </div>
            )}
            {canPrev && (
              <div
                onPointerDown={onDown("prev")}
                onPointerMove={onMove}
                onPointerUp={onUp}
                onPointerCancel={onUp}
                onPointerEnter={(e) => {
                  if (e.pointerType === "mouse") setPeek("prev");
                }}
                onPointerLeave={() => setPeek(null)}
                className={`pointer-events-auto absolute bottom-0 left-0 h-[26%] cursor-grab touch-none select-none active:cursor-grabbing ${spread ? "w-[13%]" : "w-[18%]"}`}
              />
            )}
          </div>
        </div>
      </div>

      <div className="flex items-center justify-between gap-3 border-t border-gray-200 pt-5">
        <button type="button" onClick={goPrev} disabled={!canPrev} className={btn}>
          <svg viewBox="0 0 16 16" className="size-3.5" aria-hidden="true">
            <path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" strokeWidth="1.8" />
          </svg>
          前へ
        </button>
        <div className="min-w-0 flex-1 text-center">
          <p className="text-[12px] text-gray-500" aria-live="polite">
            <span className={`${num} text-[15px] font-bold text-gray-900`}>{readout}</span>
            <span className={`${num} ml-1.5`}>/ {pad(TOTAL)}</span>
          </p>
          <div className="mx-auto mt-2 h-px max-w-[14rem] bg-gray-200" aria-hidden="true">
            <div
              className={`h-px origin-left bg-amber-600 ${reduced ? "" : "transition-transform duration-500"}`}
              style={{ transform: `scaleX(${progress})` }}
            />
          </div>
        </div>
        <button type="button" onClick={goNext} disabled={!canNext} className={btn}>
          次へ
          <svg viewBox="0 0 16 16" className="size-3.5" aria-hidden="true">
            <path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.8" />
          </svg>
        </button>
      </div>

      <nav aria-label="ページ一覧" className="mt-6">
        <ol className="grid grid-cols-2 border-t border-l border-gray-200 sm:grid-cols-4">
          {PAGES.map((p, i) => {
            const current = visible.has(i);
            return (
              <li key={p.title} className="border-r border-b border-gray-200">
                <button
                  type="button"
                  onClick={() => goTo(i)}
                  aria-current={current ? "page" : undefined}
                  className={`flex w-full items-baseline gap-2.5 px-3 py-2.5 text-left text-[12.5px] transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                    current ? "bg-amber-50 text-gray-900" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
                  }`}
                >
                  <span className={`${num} text-[11px] font-semibold ${current ? "text-amber-700" : "text-gray-400"}`}>{pad(i + 1)}</span>
                  <span className={current ? "font-bold" : ""}>{p.title}</span>
                </button>
              </li>
            );
          })}
        </ol>
      </nav>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/page-flip-brochure.json