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`}>'{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