"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export type PageFlipView = "auto" | "spread" | "single";
export interface PageFlipProps extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
/** Page contents, in reading order. Page 1 is the cover (shown alone on the right in spreads). */
pages: React.ReactNode[];
/** Accessible name of the book. */
label?: string;
/** Page width in px (shrinks to fit narrow containers). */
width?: number;
/** Page height as a multiple of its width. */
ratio?: number;
/** Two-page spreads, single pages, or spreads only when the container is wide enough. */
view?: PageFlipView;
/** Duration of one page turn, in ms. */
duration?: number;
/** Strength of the curl shading on turning pages, 0–1. */
shading?: number;
/** Small page numbers in the outer bottom corners. */
folios?: boolean;
/** Previous / next buttons and the counter. */
showControls?: boolean;
/** Focus ring and control colour (any CSS colour). */
accent?: string;
/** Initially shown page (0-based). */
defaultPage?: number;
/** Called with the first visible page (0-based) when it changes. */
onPageChange?: (page: number) => void;
}
type Mode = "spread" | "single";
interface GripDrag {
id: number;
forward: boolean;
leaf: number;
spineX: number;
pageW: number;
x0: number;
angle: number;
t: number;
v: number;
}
const EASE = [0.645, 0.045, 0.355, 1] as const; // ease-in-out cubic: lift, glide, settle
const STAGGER = 0.07; // seconds between leaves when several turn at once
const iconButton =
"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white text-zinc-700 outline-hidden transition-colors hover:border-zinc-900/25 hover:text-zinc-950 aria-disabled:opacity-35 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pf-accent)] dark:border-white/12 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white";
const paper = "bg-[#FBFAF6] dark:bg-[#1B1E22]";
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={dir === "left" ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} />
</svg>
);
}
interface LeafProps {
index: number;
leaves: number;
turned: number;
front: React.ReactNode;
back: React.ReactNode;
frontVisible: boolean;
backVisible: boolean;
frontFolio: number | null;
backFolio: number | null;
duration: number;
shading: number;
instant: boolean;
register: (index: number, value: MotionValue<number> | null) => void;
}
/**
* One sheet of paper hinged at the spine: front face on the right, back face (pre-rotated 180°)
* on the left. Its angle is a motion value that animates whenever the number of turned leaves
* crosses it; the parent can also drive it directly while a corner is dragged.
*/
function Leaf({ index, leaves, turned, front, back, frontVisible, backVisible, frontFolio, backFolio, duration, shading, instant, register }: LeafProps) {
const target = index < turned ? -180 : 0;
const rot = useMotionValue(target);
const prevTurned = React.useRef(turned);
React.useEffect(() => {
register(index, rot);
return () => register(index, null);
}, [index, rot, register]);
React.useEffect(() => {
const from = prevTurned.current;
prevTurned.current = turned;
if (instant) {
rot.set(target);
return;
}
// When several leaves turn at once (Home/End, view changes), riffle them in reading order.
const order = turned > from ? index - from : from - 1 - index;
const remaining = Math.abs(target - rot.get()) / 180;
const controls = animate(rot, target, {
duration: Math.max(0.12, (duration / 1000) * remaining),
ease: EASE,
delay: Math.max(0, order) * STAGGER,
});
return () => controls.stop();
}, [target, turned, index, duration, instant, rot]);
// On the right half the earliest leaf is on top; once past 90° the latest one is.
const zIndex = useTransform(rot, (r) => (r > -90 ? leaves - index : index + 1));
const lift = useTransform(rot, (r) => Math.round(Math.sin((-r * Math.PI) / 180) * shading * 1000) / 1000);
return (
<motion.div
className="absolute inset-y-0 left-[var(--pf-spine)] w-[var(--pf-w)] origin-left [transform-style:preserve-3d]"
style={{ rotateY: rot, zIndex }}
>
{/* Front: a right-hand page. */}
<div
aria-hidden={!frontVisible}
inert={!frontVisible}
className={`absolute inset-0 overflow-hidden rounded-r-[3px] border border-zinc-900/10 [backface-visibility:hidden] dark:border-white/10 ${paper}`}
>
<div className="h-full w-full">{front}</div>
{frontFolio !== null && (
<span aria-hidden="true" className="absolute bottom-2.5 right-3 font-mono text-[9px] tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{String(frontFolio).padStart(2, "0")}
</span>
)}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgb(0_0_0/0.09),transparent_9%)]" />
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgb(0_0_0/0.38),rgb(0_0_0/0.06)_32%,rgb(255_255_255/0.16)_68%,rgb(0_0_0/0.18))]"
style={{ opacity: lift }}
/>
</div>
{/* Back: a left-hand page, mirrored so it reads correctly once the leaf has turned. */}
<div
aria-hidden={!backVisible}
inert={!backVisible}
className={`absolute inset-0 overflow-hidden rounded-l-[3px] border border-zinc-900/10 [backface-visibility:hidden] [transform:rotateY(180deg)] dark:border-white/10 ${paper}`}
>
<div className="h-full w-full">{back}</div>
{backFolio !== null && (
<span aria-hidden="true" className="absolute bottom-2.5 left-3 font-mono text-[9px] tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{String(backFolio).padStart(2, "0")}
</span>
)}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_left,rgb(0_0_0/0.09),transparent_9%)]" />
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_left,rgb(0_0_0/0.38),rgb(0_0_0/0.06)_32%,rgb(255_255_255/0.16)_68%,rgb(0_0_0/0.18))]"
style={{ opacity: lift }}
/>
</div>
</motion.div>
);
}
/**
* A book whose pages turn around the spine in CSS 3D. Wide containers show two-page spreads,
* narrow ones a single page. Turn with the buttons, the arrow keys while the book is focused, or
* by dragging a page's outer edge — the page follows the pointer and completes or falls back
* depending on how far (and how fast) it went. Only the visible pages are interactive; the rest
* are inert and hidden from assistive tech, and the visible pages are announced.
*/
export function PageFlip({
pages,
label = "Book",
width = 240,
ratio = 1.38,
view = "auto",
duration = 700,
shading = 0.6,
folios = true,
showControls = true,
accent = "#FF7448",
defaultPage = 0,
onPageChange,
className = "",
style,
...props
}: PageFlipProps) {
const reduceMotion = usePrefersReducedMotion();
const n = pages.length;
const rootRef = React.useRef<HTMLElement>(null);
const bookRef = React.useRef<HTMLDivElement>(null);
const leafValues = React.useRef(new Map<number, MotionValue<number>>());
const onPageChangeRef = React.useRef(onPageChange);
const drag = React.useRef<GripDrag | null>(null);
const [page, setPage] = React.useState(() => Math.max(0, Math.min(n - 1, Math.round(defaultPage))));
const [layout, setLayout] = React.useState<{ mode: Mode; pageW: number }>({ mode: view === "single" ? "single" : "spread", pageW: width });
const [dragging, setDragging] = React.useState(false);
const mode: Mode = view === "auto" ? layout.mode : view;
const pageW = Math.round(Math.min(width, layout.pageW));
const pageH = Math.round(pageW * ratio);
const spread = mode === "spread";
// Leaves: in spreads each sheet carries two pages (front = right, back = left); single view
// gives every page its own sheet with a blank back.
const leafCount = spread ? Math.ceil(n / 2) : n;
const maxTurned = spread ? Math.floor(n / 2) : n - 1;
const turned = Math.min(maxTurned, spread ? Math.floor((page + 1) / 2) : page);
const visible = spread ? [turned * 2 - 1, turned * 2].filter((p) => p >= 0 && p < n) : [page];
const first = visible[0] ?? 0;
React.useEffect(() => {
onPageChangeRef.current = onPageChange;
});
// Measure the container: choose spread or single (in auto) and shrink pages to fit.
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const ro = new ResizeObserver(([entry]) => {
const available = entry.contentRect.width - 24;
const wide = available >= width * 2;
const nextMode: Mode = view === "single" || (view === "auto" && !wide) ? "single" : "spread";
const nextW = Math.max(120, Math.min(width, nextMode === "spread" ? available / 2 : available));
setLayout((prev) => (prev.mode === nextMode && Math.abs(prev.pageW - nextW) < 1 ? prev : { mode: nextMode, pageW: nextW }));
});
ro.observe(root);
return () => ro.disconnect();
}, [width, view]);
const register = React.useCallback((index: number, value: MotionValue<number> | null) => {
if (value) leafValues.current.set(index, value);
else leafValues.current.delete(index);
}, []);
const goToTurned = (t: number) => {
const next = Math.max(0, Math.min(maxTurned, t));
const nextPage = spread ? (next === 0 ? 0 : next * 2 - 1) : next;
if (nextPage === page) return;
setPage(nextPage);
onPageChangeRef.current?.(nextPage);
};
/* --------------------------------- Drag --------------------------------- */
const angleAt = (d: GripDrag, clientX: number) => {
if (spread) {
// The page's outer edge follows the pointer: x = spine + w·cos θ.
const c = Math.max(-1, Math.min(1, (clientX - d.spineX) / d.pageW));
return (Math.acos(c) * 180) / Math.PI;
}
const p = Math.max(0, Math.min(1, (d.forward ? d.x0 - clientX : clientX - d.x0) / d.pageW));
return d.forward ? p * 180 : 180 - p * 180;
};
const onGripDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || !bookRef.current) return;
const forward = e.currentTarget.dataset.direction === "next";
const leaf = forward ? turned : turned - 1;
const value = leafValues.current.get(leaf);
if (!value) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
value.stop();
const rect = bookRef.current.getBoundingClientRect();
const scale = rect.width / (spread ? pageW * 2 : pageW) || 1;
drag.current = {
id: e.pointerId,
forward,
leaf,
spineX: rect.left + (spread ? rect.width / 2 : 0),
pageW: pageW * scale,
x0: e.clientX,
angle: -value.get(),
t: e.timeStamp,
v: 0,
};
setDragging(true);
};
const onGripMove = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const angle = angleAt(d, e.clientX);
const dt = Math.max(1, e.timeStamp - d.t) / 1000;
d.v = d.v * 0.5 + ((angle - d.angle) / dt) * 0.5;
d.angle = angle;
d.t = e.timeStamp;
leafValues.current.get(d.leaf)?.set(-angle);
};
const onGripUp = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
setDragging(false);
const flick = e.timeStamp - d.t > 90 ? 0 : d.v; // degrees per second
const complete = d.forward ? d.angle > 90 || flick > 320 : d.angle < 90 || flick < -320;
if (complete && Math.abs(d.angle - (d.forward ? 0 : 180)) > 2) {
goToTurned(d.forward ? turned + 1 : turned - 1);
return;
}
// Fall back to where it was.
const value = leafValues.current.get(d.leaf);
if (!value) return;
const home = d.forward ? 0 : -180;
if (reduceMotion) value.set(home);
else animate(value, home, { duration: Math.max(0.15, ((duration / 1000) * Math.abs(home - value.get())) / 180), ease: EASE });
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
const keys: Record<string, () => void> = {
ArrowLeft: () => goToTurned(turned - 1),
ArrowRight: () => goToTurned(turned + 1),
Home: () => goToTurned(0),
End: () => goToTurned(maxTurned),
};
const action = keys[e.key];
if (!action) return;
e.preventDefault();
action();
};
if (!n) return null;
// Spreads keep the visible paper centred: shift right on the cover, left on the back cover.
const shift = spread ? (turned === 0 ? -pageW / 2 : turned === leafCount && n % 2 === 0 ? pageW / 2 : 0) : 0;
const canPrev = turned > 0;
const canNext = turned < maxTurned;
const announcement =
visible.length === 2 ? `Pages ${visible[0] + 1}–${visible[1] + 1} of ${n}` : `Page ${first + 1} of ${n}`;
const grip = Math.max(36, Math.round(pageW * 0.16));
return (
<section
ref={rootRef}
aria-roledescription="book"
aria-label={label}
className={`relative w-full ${className}`}
style={{ ["--pf-accent" as string]: accent, ...style }}
{...props}
>
<div
role="group"
aria-label={`${label}: use the Left and Right arrow keys to turn pages`}
tabIndex={0}
onKeyDown={onKeyDown}
className="mx-auto w-fit rounded-md px-3 py-6 outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pf-accent)]"
>
{/* Single view hides the turned stack to the left of the spine; nothing else is clipped. */}
<div style={!spread ? { clipPath: "inset(-64px -64px -64px 0)" } : undefined}>
<div
ref={bookRef}
className={`relative ${dragging ? "select-none" : ""} transition-transform duration-500 ease-[cubic-bezier(0.645,0.045,0.355,1)] motion-reduce:transition-none`}
style={{
width: spread ? pageW * 2 : pageW,
height: pageH,
perspective: pageW * 5,
translate: `${Math.round(shift)}px 0`,
["--pf-w" as string]: `${pageW}px`,
["--pf-spine" as string]: spread ? `${pageW}px` : "0px",
}}
>
{/* Page block: stacked edges under whichever side still holds paper. */}
{spread && canPrev && (
<div
aria-hidden="true"
className="absolute inset-y-0 left-0 w-[var(--pf-w)] rounded-l-[3px] bg-[#EFEBE2] shadow-[-1px_1px_0_#E3DED3,-2px_2px_0_#D9D3C6,-3px_3px_0_#CFC8BA,0_24px_44px_-24px_rgb(0_0_0/0.45)] dark:bg-[#24282D] dark:shadow-[-1px_1px_0_#2B3036,-2px_2px_0_#30353B,-3px_3px_0_#353A41,0_24px_44px_-24px_rgb(0_0_0/0.8)]"
/>
)}
{turned < leafCount && (
<div
aria-hidden="true"
className="absolute inset-y-0 left-[var(--pf-spine)] w-[var(--pf-w)] rounded-r-[3px] bg-[#EFEBE2] shadow-[1px_1px_0_#E3DED3,2px_2px_0_#D9D3C6,3px_3px_0_#CFC8BA,0_24px_44px_-24px_rgb(0_0_0/0.45)] dark:bg-[#24282D] dark:shadow-[1px_1px_0_#2B3036,2px_2px_0_#30353B,3px_3px_0_#353A41,0_24px_44px_-24px_rgb(0_0_0/0.8)]"
/>
)}
{Array.from({ length: leafCount }, (_, j) => {
const frontPage = spread ? j * 2 : j;
const backPage = spread ? j * 2 + 1 : -1;
const hasBack = backPage >= 0 && backPage < n;
return (
<Leaf
// Re-create sheets when the view changes, so they start at their resting angle.
key={`${mode}-${j}`}
index={j}
leaves={leafCount}
turned={turned}
front={pages[frontPage]}
back={hasBack ? pages[backPage] : null}
frontVisible={visible.includes(frontPage)}
backVisible={hasBack && visible.includes(backPage)}
frontFolio={folios ? frontPage + 1 : null}
backFolio={folios && hasBack ? backPage + 1 : null}
duration={duration}
shading={Math.max(0, Math.min(1, shading))}
instant={reduceMotion}
register={register}
/>
);
})}
{/* Corner grips: drag a page's outer edge to turn it (pointer only; keys and buttons do the same). */}
{canNext && (
<div
aria-hidden="true"
data-direction="next"
onPointerDown={onGripDown}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onPointerCancel={onGripUp}
className="group absolute inset-y-0 right-0 z-[500] cursor-grab touch-pan-y active:cursor-grabbing"
style={{ width: grip }}
>
<span className="absolute bottom-0 right-0 size-7 rounded-tl-[10px] bg-[linear-gradient(135deg,transparent_45%,rgb(0_0_0/0.08)_50%,#F2EEE6_52%,#E4DFD4)] opacity-0 shadow-[-2px_-2px_6px_rgb(0_0_0/0.08)] transition-opacity duration-200 group-hover:opacity-100 dark:bg-[linear-gradient(135deg,transparent_45%,rgb(0_0_0/0.3)_50%,#2A2E34_52%,#33383F)]" />
</div>
)}
{canPrev && (
<div
aria-hidden="true"
data-direction="previous"
onPointerDown={onGripDown}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onPointerCancel={onGripUp}
className="group absolute inset-y-0 left-0 z-[500] cursor-grab touch-pan-y active:cursor-grabbing"
style={{ width: grip }}
>
{spread && (
<span className="absolute bottom-0 left-0 size-7 rounded-tr-[10px] bg-[linear-gradient(225deg,transparent_45%,rgb(0_0_0/0.08)_50%,#F2EEE6_52%,#E4DFD4)] opacity-0 shadow-[2px_-2px_6px_rgb(0_0_0/0.08)] transition-opacity duration-200 group-hover:opacity-100 dark:bg-[linear-gradient(225deg,transparent_45%,rgb(0_0_0/0.3)_50%,#2A2E34_52%,#33383F)]" />
)}
</div>
)}
</div>
</div>
</div>
{showControls && (
<div className="mt-2 flex items-center justify-center gap-2">
<button type="button" aria-label="Previous page" aria-disabled={!canPrev} onClick={() => goToTurned(turned - 1)} className={iconButton}>
<Chevron dir="left" />
</button>
<span aria-hidden="true" className="min-w-[8rem] text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-900 dark:text-zinc-100">{visible.map((p) => String(p + 1).padStart(2, "0")).join("–")}</span> / {String(n).padStart(2, "0")}
</span>
<button type="button" aria-label="Next page" aria-disabled={!canNext} onClick={() => goToTurned(turned + 1)} className={iconButton}>
<Chevron dir="right" />
</button>
</div>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</div>
</section>
);
}