"use client"; import { useRef, useState, type ReactNode } from "react"; import { motion, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react"; type FlipBook3DProps = { /** Pages in reading order. The first is the cover. Two pages sit on each leaf. */ pages: ReactNode[]; /** Width of one page divided by its height. */ ratio?: number; /** Name read out by screen readers. */ label?: string; /** Look of each page. */ pageClassName?: string; className?: string; }; type FaceProps = { children: ReactNode; page: MotionValue; index: number; back?: boolean; open: boolean; className: string; }; type LeafProps = { index: number; count: number; page: MotionValue; front: ReactNode; back: ReactNode; frontOpen: boolean; backOpen: boolean; pageClassName: string; }; const TURN = { stiffness: 110, damping: 21 }; const GAP = 0.6; const HIDDEN = { backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" } as const; function Face({ children, page, index, back, open, className }: FaceProps) { const shade = useTransform(page, (p) => Math.sin(Math.max(0, Math.min(1, p - index)) * Math.PI) * 0.4); return (
{children}
); } function Leaf({ index, count, page, front, back, frontOpen, backOpen, pageClassName }: LeafProps) { const rotateY = useTransform(page, [index, index + 1], [0, -180]); const z = useTransform(page, [index, index + 1], [(count - index) * GAP, index * GAP]); return ( {front} {back} ); } /* FlipBook3D: a real book in CSS 3D. Drag a page across the spine, click a side, or use the arrows and keys. Links on the open pages still work. */ export function FlipBook3D({ pages, ratio = 0.72, label = "Book", pageClassName = "bg-[#f7f3ea] text-[#1c1917]", className = "", }: FlipBook3DProps) { const reduce = useReducedMotion(); const leaves = Math.ceil(pages.length / 2); const book = useRef(null); const press = useRef<{ x: number; moved: boolean } | null>(null); const [spread, setSpread] = useState(0); const page = useSpring(0, TURN); const x = useTransform(page, [0, Math.min(1, leaves / 2), Math.max(leaves - 1, leaves / 2), leaves], ["-25%", "0%", "0%", "25%"]); const total = pages.length; const left = spread * 2; const status = spread === 0 ? `Page 1 of ${total}` : left < total ? `Pages ${left} and ${left + 1} of ${total}` : `Page ${total} of ${total}`; const go = (to: number) => { const next = Math.max(0, Math.min(leaves, to)); setSpread(next); if (reduce) page.jump(next); else page.set(next); }; const onDown = (e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("a, button, input, select, textarea")) return; press.current = { x: e.clientX, moved: false }; }; const onMove = (e: React.PointerEvent) => { const held = press.current; if (!held || !book.current) return; const dx = e.clientX - held.x; if (!held.moved && Math.abs(dx) > 6) { held.moved = true; e.currentTarget.setPointerCapture(e.pointerId); } if (held.moved && !reduce) page.set(Math.max(0, Math.min(leaves, spread - dx / (book.current.offsetWidth * 0.6)))); }; const onUp = (e: React.PointerEvent) => { const held = press.current; press.current = null; if (!held) return; if (held.moved) { const dx = e.clientX - held.x; go(Math.abs(dx) < 30 ? spread : spread - Math.sign(dx)); } else { const r = e.currentTarget.getBoundingClientRect(); go(spread + (e.clientX > r.left + r.width / 2 ? 1 : -1)); } }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight") go(spread + 1); else if (e.key === "ArrowLeft") go(spread - 1); else return; e.preventDefault(); }; const arrow = "grid size-8 place-items-center rounded-full border border-white/15 text-white/80 transition-colors hover:bg-white/10 disabled:opacity-30"; return (
{Array.from({ length: leaves }, (_, i) => ( ))}
{status}
); }