Interactive
3DFlipBook
A book in CSS 3D. Drag a page across the spine and it follows your hand, or click a side, use the arrows or the keys. Closed, the cover sits in the middle. Links on the open pages still work.
Download
- React
- Tailwind CSS v4
- motion
components/ui/3d-flip-book.tsx
"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<number>;
index: number;
back?: boolean;
open: boolean;
className: string;
};
type LeafProps = {
index: number;
count: number;
page: MotionValue<number>;
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 (
<div inert={!open} className={`absolute inset-0 overflow-hidden ${className}`} style={{ ...HIDDEN, transform: back ? "rotateY(180deg)" : undefined }}>
{children}
<div
aria-hidden
className={`pointer-events-none absolute inset-y-0 w-1/5 from-black/25 to-transparent ${back ? "right-0 bg-gradient-to-l" : "left-0 bg-gradient-to-r"}`}
/>
<motion.div aria-hidden className="pointer-events-none absolute inset-0 bg-black" style={{ opacity: shade }} />
</div>
);
}
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 (
<motion.div className="absolute inset-y-0 left-1/2 w-1/2" style={{ rotateY, z, transformStyle: "preserve-3d", transformOrigin: "0% 50%" }}>
<Face page={page} index={index} open={frontOpen} className={pageClassName}>
{front}
</Face>
<Face page={page} index={index} open={backOpen} className={pageClassName} back>
{back}
</Face>
</motion.div>
);
}
/* 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<HTMLDivElement>(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<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest("a, button, input, select, textarea")) return;
press.current = { x: e.clientX, moved: false };
};
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div role="group" aria-label={label} tabIndex={0} onKeyDown={onKey} className={`outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}>
<div style={{ perspective: 1800 }}>
<motion.div
ref={book}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="relative w-full cursor-grab touch-pan-y select-none active:cursor-grabbing"
style={{ aspectRatio: ratio * 2, x, rotateX: 8, transformStyle: "preserve-3d" }}
>
{Array.from({ length: leaves }, (_, i) => (
<Leaf
key={i}
index={i}
count={leaves}
page={page}
front={pages[i * 2]}
back={pages[i * 2 + 1]}
frontOpen={spread === i}
backOpen={spread === i + 1}
pageClassName={pageClassName}
/>
))}
</motion.div>
</div>
<div className="mt-3 flex items-center justify-center gap-3 text-xs text-white/60">
<button type="button" aria-label="Previous page" disabled={spread === 0} onClick={() => go(spread - 1)} className={arrow}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m15 6-6 6 6 6" />
</svg>
</button>
<span role="status">{status}</span>
<button type="button" aria-label="Next page" disabled={spread === leaves} onClick={() => go(spread + 1)} className={arrow}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m9 6 6 6-6 6" />
</svg>
</button>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/3d-flip-book.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/3d-flip-book-demo.tsx
import { FlipBook3D } from "@/components/ui/3d-flip-book"; const COVER = "grid h-full place-items-center bg-[#4c1d95] p-4 text-center text-white"; const PAGE = "grid h-full content-center gap-1 p-3 text-[11px] leading-snug"; const PAGES = [ <div key="cover" className={COVER}> <p className="text-base font-semibold tracking-tight">Field Notes</p> </div>, <div key="1" className={PAGE}> <p className="font-semibold">Morning</p> <p>Mist on the lake. Two herons near the old jetty.</p> </div>, <div key="2" className={PAGE}> <p className="font-semibold">Noon</p> <p>Followed the river east. Water low for this month.</p> </div>, <div key="3" className={PAGE}> <p className="font-semibold">Evening</p> <p>Camp under the ridge. Sky went violet by six.</p> </div>, <div key="4" className={PAGE}> <p className="font-semibold">Night</p> <p>No wind. Counted owls until the fire went low.</p> </div>, <div key="back" className={COVER}> <p className="text-xs">The end</p> </div>, ]; export default function FlipBook3DExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-4"> <FlipBook3D pages={PAGES} label="Field notes" className="w-full max-w-[250px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| pages | ReactNode[] | โ | Pages in reading order. The first is the cover. Two pages sit on each leaf. |
| ratio | number | 0.72 | Width of one page divided by its height. |
| label | string | "Book" | Read by screen readers. |
| pageClassName | string | paper colours | Look of each page. |
| className | string | "" | On the wrapper. Set the width here. |