BLK-89Blocks

ページをめくるデジタルパンフレット

8ページの会社案内を見開きで読ませるデジタルパンフレット(flipbook)。紙は CSS の perspective と preserve-3d の上に立てた「葉」で、右下の角をつまんで引くと紙の端が指に付いてくる角度でめくれ、指を止めればその角度のまま待つ。離した位置が半分を越えていればめくり切り、越えていなければ戻る。重なり順は z-index ではなく葉ごとの translateZ で決めるので、ページ一覧から数ページ先へ飛んでも複数枚が時差でめくれて破綻しない。器の幅が狭いと1ページ表示に切り替わり、めくった紙は枠の外へ出ていく。矢印キー・PageUp/Down・Home/End と前後のボタン、ページ一覧だけで操作が完結し、見えていない面は inert にして読み上げを今の見開きに絞る。中の文字はページ幅に比例する cqi 単位で組み、数字・推移の棒グラフ・料金表・事例の比較までページの中で読める。prefers-reduced-motion ではめくりを省いて即座に差し替え、静止した紙面として読める。3Dライブラリも画像素材も使わず、英字の小見出し・末尾の矢印リンク・角丸・スクロール出現も使わない。掲載内容はすべて見本。

追加日:
2026-10-04
依存:
なし
tags
#flipbook #page-flip #brochure #css-3d #drag #keyboard #data-visualization #container-query #interactive #no-image

プレビュー

めくって読む会社案内

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

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

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

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