Book Flip
Book Flip
A tactile 3D book with bending pages, realistic light, and interactive page turns.
Select a page or click the book to turn it.
Component preview
Book Flip
Install using CLI
npx shadcn@latest add "https://www.obsidianui.dev/r/book-flip.json"Usage
Code
import { BookFlip } from "@/components/block/book-flip";
export default function Demo() {
return <BookFlip className="h-[400px]" />;
}Install Manually
1
Install dependencies
npm install @react-three/drei @react-three/fiber clsx maath tailwind-merge three
2
Copy the source code
Copy into components/block/book-flip.jsx
"use client";
import { Suspense, useEffect } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { Experience } from "@/lib/effects/book-flip/Experience";
import { PageProvider, usePage } from "@/lib/effects/book-flip/PageContext";
import { WebGLSurface, useEffectReducedMotion } from "@/lib/effects/shared/webgl-surface";
const defaultImages = Array.from({ length: 14 }, (_, index) => `book-flip-img${String(index + 1).padStart(2, "0")}`);
const defaultCameraDistance = { mobile: 5.5, desktop: 4 };
function CameraFit({ cameraDistance }) {
const { camera, size } = useThree();
useEffect(() => {
camera.position.set(-0.5, 1, size.width < 480 ? cameraDistance.mobile : cameraDistance.desktop);
camera.updateProjectionMatrix();
}, [camera, size.width, cameraDistance]);
return null;
}
function BookNavigation({ images }) {
const { page, setPage } = usePage();
const count = Math.ceil(images.length / 2);
return <div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center p-3">
<div role="group" aria-label="Book pages" className="pointer-events-auto flex max-w-full gap-2 overflow-x-auto rounded-full bg-black/20 p-1">
{Array.from({ length: count + 1 }, (_, index) => <button
key={index}
type="button"
aria-pressed={index === page}
onClick={() => setPage(index)}
className={`shrink-0 rounded-full px-3 py-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white ${index === page ? "bg-white/90 text-black" : "bg-black/30 text-white"}`}
>{index === 0 ? "Cover" : index === count ? "Back cover" : `Page ${index}`}</button>)}
</div>
</div>;
}
function BookScene({ images, pathPattern, bgColor, cameraDistance, showUI }) {
const reducedMotion = useEffectReducedMotion();
return <PageProvider>
<Canvas
frameloop={reducedMotion ? "demand" : "always"}
dpr={[1, 2]}
style={{ position: "absolute", inset: 0, background: bgColor }}
camera={{ position: [-0.5, 1, cameraDistance.desktop], fov: 45 }}
>
<CameraFit cameraDistance={cameraDistance} />
<Suspense fallback={null}>
<Experience images={images} pathPattern={pathPattern} orbitControls={{ minAzimuthAngle: -Math.PI * 0.06, maxAzimuthAngle: Math.PI * 0.06, minPolarAngle: 1.07, maxPolarAngle: 1.58, rotateSpeed: 0.2, enableDamping: !reducedMotion }} />
</Suspense>
</Canvas>
{showUI && <BookNavigation images={images} />}
</PageProvider>;
}
/**
* Images are PNG page names without their extension, resolved against pathPattern.
* @param {{ images?: string[], pathPattern?: string, bgColor?: string, cameraDistance?: { mobile: number, desktop: number }, showUI?: boolean, className?: string, style?: import("react").CSSProperties }} props
*/
export function BookFlip({ images = defaultImages, pathPattern = "/effects/book-flip", bgColor = "#000000", cameraDistance = defaultCameraDistance, showUI = true, className, style } = {}) {
return <WebGLSurface className={className} style={style} imageSrc={`${pathPattern}/${images[0] || "book-flip-img01"}.png`} label="ObsidianUI interactive nature book">
<BookScene images={images} pathPattern={pathPattern} bgColor={bgColor} cameraDistance={cameraDistance} showUI={showUI} />
</WebGLSurface>;
}3
Add the supporting file
Copy into lib/effects/book-flip/Book.jsx
'use client'
import { useTexture } from"@react-three/drei";
import { useFrame } from"@react-three/fiber";
import { useEffectReducedMotion } from "@/lib/effects/shared/webgl-surface";
import { easing } from"maath";
import { usePage } from"./PageContext";
import { useEffect, useMemo, useRef, useState } from"react";
import {
Bone,
BoxGeometry,
Color,
Float32BufferAttribute,
MeshStandardMaterial,
Skeleton,
SkinnedMesh,
SRGBColorSpace,
Uint16BufferAttribute,
Vector3,
} from"three";
import { degToRad } from"three/src/math/MathUtils.js";
/**
* CONFIG CONSTANTS - Adjust these to customize the book appearance and animation
*/
const EASING_FACTOR = 0.5; // Controls the speed of page rotation easing (lower = slower)
const EASING_FACTOR_FOLD = 0.3; // Controls the speed of page fold animation
const INSIDE_CURVE_STRENGTH = 0.18; // How much the inside of the page curves when opened
const OUTSIDE_CURVE_STRENGTH = 0.05; // How much the outside edges curve
const TURNING_CURVE_STRENGTH = 0.09; // Curve strength during page turning animation
const PAGE_WIDTH = 1.28; // Width of a single page
const PAGE_HEIGHT = 1.71; // Height of a single page (4:3 aspect ratio)
const PAGE_DEPTH = 0.003; // Thickness of paper
const PAGE_SEGMENTS = 30; // Number of bone segments (more = more flexible, but slower)
const SEGMENT_WIDTH = PAGE_WIDTH / PAGE_SEGMENTS;
/**
* GEOMETRY SETUP
* Creates a segmented geometry that can be deformed by skeleton bones
* Each segment is controlled by 2 bones for smooth deformation
*/
const pageGeometry = new BoxGeometry(
PAGE_WIDTH,
PAGE_HEIGHT,
PAGE_DEPTH,
PAGE_SEGMENTS,
2
);
// Translate geometry so rotation happens from the left edge
pageGeometry.translate(PAGE_WIDTH / 2, 0, 0);
// Setup skin weights and indices for skeletal animation
const position = pageGeometry.attributes.position;
const vertex = new Vector3();
const skinIndexes = [];
const skinWeights = [];
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const x = vertex.x;
// Each vertex is influenced by 2 adjacent bones
const skinIndex = Math.max(0, Math.floor(x / SEGMENT_WIDTH));
const skinWeight = (x % SEGMENT_WIDTH) / SEGMENT_WIDTH;
// Store bone influences (up to 4 per vertex, we use 2)
skinIndexes.push(skinIndex, skinIndex + 1, 0, 0);
skinWeights.push(1 - skinWeight, skinWeight, 0, 0);
}
pageGeometry.setAttribute(
"skinIndex",
new Uint16BufferAttribute(skinIndexes, 4)
);
pageGeometry.setAttribute(
"skinWeight",
new Float32BufferAttribute(skinWeights, 4)
);
// Base materials for pages
const whiteColor = new Color("white");
const emissiveColor = new Color("orange");
const pageMaterials = [
new MeshStandardMaterial({ color: whiteColor }),
new MeshStandardMaterial({ color:"#111" }),
new MeshStandardMaterial({ color: whiteColor }),
new MeshStandardMaterial({ color: whiteColor }),
];
/**
* Helper function to generate page pairs from image array
* Creates front/back pairs: [0,1], [2,3], [4,5], etc.
*/
const generatePages = (imageArray) => {
if (!imageArray || imageArray.length === 0) {
return [];
}
const pages = [
{
front: imageArray[0],
back: imageArray[1] || imageArray[0],
},
];
for (let i = 2; i < imageArray.length - 1; i += 2) {
pages.push({
front: imageArray[i],
back: imageArray[i + 1],
});
}
if (imageArray.length % 2 === 1) {
pages.push({
front: imageArray[imageArray.length - 1],
back: imageArray[0],
});
}
return pages;
};
/**
* Preload textures for better performance
*/
const preloadTextures = (pages, pathPattern) => {
pages.forEach((page) => {
useTexture.preload(`${pathPattern}/${page.front}.png`);
useTexture.preload(`${pathPattern}/${page.back}.png`);
});
};
/**
* PAGE COMPONENT
* Represents a single page in the book with skinned mesh deformation
* * ANIMATION LOGIC:
* - Uses skeleton with 31 bones along the page width
* - Each bone rotates based on whether the page is opened
* - Inner bones curve more (3D effect), outer bones curve less
* - During turning, bones follow a sine wave for smooth animation
*/
const Page = ({ number, front, back, page, opened, bookClosed,
pathPattern,
...props }) => {
const frontPath = `${pathPattern}/${front}.png`;
const backPath = `${pathPattern}/${back}.png`;
const pictures = useTexture([frontPath, backPath]);
const [picture, picture2] = useMemo(() => pictures.map((original) => {
const texture = original.clone();
texture.colorSpace = SRGBColorSpace;
return texture;
}), [pictures]);
const reducedMotion = useEffectReducedMotion();
const group = useRef();
const turnedAt = useRef(0);
const lastOpened = useRef(opened);
const skinnedMeshRef = useRef();
/**
* Create the skeletal mesh for this page
* - Creates 31 bones in a chain
* - Attaches them hierarchically so rotation cascades
* - Wraps geometry in skeleton for deformation
*/
const manualSkinnedMesh = useMemo(() => {
const bones = [];
// Create bone chain
for (let i = 0; i <= PAGE_SEGMENTS; i++) {
const bone = new Bone();
bones.push(bone);
if (i === 0) {
bone.position.x = 0; // Root bone at spine
} else {
bone.position.x = SEGMENT_WIDTH; // Each subsequent bone positioned relative to parent
}
if (i > 0) {
bones[i - 1].add(bone); // Attach to parent bone
}
}
const skeleton = new Skeleton(bones);
// Materials: 4 base materials + 2 textured materials (front and back)
const materials = [
...pageMaterials.map((material) => material.clone()),
new MeshStandardMaterial({
color: whiteColor,
map: picture,
roughness: 0.1,
emissive: emissiveColor,
emissiveIntensity: 0,
}),
new MeshStandardMaterial({
color: whiteColor,
map: picture2,
roughness: 0.1,
emissive: emissiveColor,
emissiveIntensity: 0,
}),
];
const mesh = new SkinnedMesh(pageGeometry.clone(), materials);
mesh.frustumCulled = false;
mesh.add(skeleton.bones[0]);
mesh.bind(skeleton); // Bind skeleton to mesh
return mesh;
}, [picture, picture2]);
useEffect(() => () => {
manualSkinnedMesh.geometry.dispose();
manualSkinnedMesh.material.forEach((material) => material.dispose());
manualSkinnedMesh.skeleton.dispose();
picture.dispose();
picture2.dispose();
}, [manualSkinnedMesh, picture, picture2]);
/**
* ANIMATION LOOP
* Updates bone rotations each frame to create page flip effect
*/
useFrame((_, delta) => {
if (!skinnedMeshRef.current) return;
// Highlight page on hover by increasing emissive intensity
// const emissiveIntensity = highlighted ? 0.22 : 0;
// skinnedMeshRef.current.material[4].emissiveIntensity =
// skinnedMeshRef.current.material[5].emissiveIntensity = MathUtils.lerp(
// skinnedMeshRef.current.material[4].emissiveIntensity,
// emissiveIntensity,
// 0.1
// );
// Track when page opened state changed for animation timing
if (lastOpened.current !== opened) {
turnedAt.current = +new Date();
lastOpened.current = opened;
}
// Calculate animation progress (0 to 1, then sin for smooth curve)
let turningTime = Math.min(400, new Date() - turnedAt.current) / 400;
turningTime = reducedMotion ? 0 : Math.sin(turningTime * Math.PI);
// Target rotation: opened = -90°, closed = +90°
let targetRotation = opened ? -Math.PI / 2 : Math.PI / 2;
// Add slight extra rotation based on page number (pages fan out slightly)
if (!bookClosed) {
targetRotation += degToRad(number * 0.8);
}
/**
* BONE ANIMATION CALCULATION
* Each bone along the page rotates differently:
* - Inside bones (0-8): Follow sin curve for inside curl effect
* - Outside bones (8+): Follow cos curve for outside edge effect
* - All bones smoothly interpolate to target rotation using dampAngle
*/
const bones = skinnedMeshRef.current.skeleton.bones;
for (let i = 0; i < bones.length; i++) {
const target = i === 0 ? group.current : bones[i];
// Inner curve: creates 3D curl effect on the inside
const insideCurveIntensity = i < 8 ? Math.sin(i * 0.2 + 0.25) : 0;
// Outer curve: pages don't rotate as much at the edges
const outsideCurveIntensity = i >= 8 ? Math.cos(i * 0.3 + 0.09) : 0;
// Turning animation: smooth wave during the page turn
const turningIntensity =
Math.sin(i * Math.PI * (1 / bones.length)) * turningTime;
// Combine all rotation influences
let rotationAngle =
INSIDE_CURVE_STRENGTH * insideCurveIntensity * targetRotation -
OUTSIDE_CURVE_STRENGTH * outsideCurveIntensity * targetRotation +
TURNING_CURVE_STRENGTH * turningIntensity * targetRotation;
// Fold rotation: subtle X-axis rotation for paper fold effect
let foldRotationAngle = degToRad(Math.sign(targetRotation) * 2);
// When book is closed, only root bone rotates
if (bookClosed) {
if (i === 0) {
rotationAngle = targetRotation;
foldRotationAngle = 0;
} else {
rotationAngle = 0;
foldRotationAngle = 0;
}
}
// Reduced motion applies the same final pose without the page-turn travel.
if (reducedMotion) {
target.rotation.y = rotationAngle;
target.rotation.x = 0;
continue;
}
easing.dampAngle(
target.rotation,
"y",
rotationAngle,
EASING_FACTOR,
delta
);
// Add fold effect to middle/end bones during turning
const foldIntensity =
i > 8
? Math.sin(i * Math.PI * (1 / bones.length) - 0.5) * turningTime
: 0;
easing.dampAngle(
target.rotation,
"x",
foldRotationAngle * foldIntensity,
EASING_FACTOR_FOLD,
delta
);
}
});
const { setPage } = usePage();
// const [highlighted, setHighlighted] = useState(false);
// useCursor(highlighted);
return (
<group
{...props}
ref={group}
// onPointerEnter={(e) => {
// e.stopPropagation();
// setHighlighted(true);
// }}
// onPointerLeave={(e) => {
// e.stopPropagation();
// setHighlighted(false);
// }}
onClick={(e) => {
e.stopPropagation();
setPage(opened ? number : number + 1);
// setHighlighted(false);
}}
>
<primitive
object={manualSkinnedMesh}
ref={skinnedMeshRef}
// Stack pages with slight depth offset
position-z={-number * PAGE_DEPTH + page * PAGE_DEPTH}
/>
</group>
);
};
/**
* BOOK COMPONENT
* Main book component that orchestrates page animation
* * ANIMATION FLOW:
* 1. User clicks to change page number in PageContext
* 2. delayedPage state animates one page at a time toward target
* 3. Each page renders and receives updated page/opened props
* 4. Pages animate their bones based on opened state
*/
export const Book = ({ images = [],
pathPattern ="/assets/nature",
...props }) => {
const { page } = usePage();
const reducedMotion = useEffectReducedMotion();
const [delayedPage, setDelayedPage] = useState(page);
// Generate pages from image array
const pages = useMemo(() => generatePages(images), [images]);
// Preload textures
useEffect(() => {
if (pages.length > 0) {
preloadTextures(pages, pathPattern);
}
}, [pages, pathPattern]);
/**
* PAGE ANIMATION SEQUENCING
* Animates delayedPage toward target page one page at a time
* This creates the sequential page-by-page animation
*/
useEffect(() => {
let timeout;
const goToPage = () => {
setDelayedPage((delayedPage) => {
if (page === delayedPage) {
return delayedPage;
} else {
// Faster animation for large jumps, slower for single pages
timeout = setTimeout(
() => {
goToPage();
},
Math.abs(page - delayedPage) > 2 ? 50 : 150
);
if (page > delayedPage) {
return delayedPage + 1;
}
if (page < delayedPage) {
return delayedPage - 1;
}
}
});
};
if (!reducedMotion) timeout = setTimeout(goToPage, 0);
return () => {
clearTimeout(timeout);
};
}, [page, reducedMotion]);
return (
<group {...props} rotation-y={-Math.PI / 2} rotation-x={-0.7} >
{pages.map((pageData, index) => (
<Page
key={index}
page={reducedMotion ? page : delayedPage}
number={index}
opened={(reducedMotion ? page : delayedPage) > index}
bookClosed={(reducedMotion ? page : delayedPage) === 0 || (reducedMotion ? page : delayedPage) === pages.length}
pathPattern={pathPattern}
{...pageData}
/>
))}
</group>
);
};4
Add the supporting file
Copy into lib/effects/book-flip/Experience.jsx
'use client'
import { Environment, OrbitControls } from"@react-three/drei";
import { Book } from"./Book";
export const Experience = ({
images = [],
pathPattern ="/assets/nature",
orbitControls = {},
...props
}) => {
return (
<>
<OrbitControls
enableDamping
enablePan={false}
enableZoom={false}
target={[0, 0, 0]}
{...orbitControls}
/>
<Book images={images} pathPattern={pathPattern} {...props} />
<Environment files="https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/book-flip/studio.hdr" />
<directionalLight
position={[2, 5, 2]}
intensity={2.5}
/>
</>
);
};5
Add the supporting file
Copy into lib/effects/book-flip/PageContext.jsx
'use client'
import { createContext, useContext, useState } from"react";
const PageContext = createContext();
export const PageProvider = ({ children }) => {
const [page, setPage] = useState(0);
return (
<PageContext.Provider value={{ page, setPage }}>
{children}
</PageContext.Provider>
);
};
export const usePage = () => {
const context = useContext(PageContext);
if (!context) {
throw new Error("usePage must be used within PageProvider");
}
return context;
};6
Add the supporting file
Copy into lib/effects/shared/webgl-surface.jsx
"use client";
import { Component, useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
const subscribeMotion = (notify) => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
};
export function useEffectReducedMotion() {
return useSyncExternalStore(subscribeMotion, () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, () => true);
}
let webglAvailable;
function supportsWebGL() {
if (webglAvailable !== undefined) return webglAvailable;
try {
const canvas = document.createElement("canvas");
const context = canvas.getContext("webgl2");
webglAvailable = Boolean(context);
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webglAvailable = false;
}
return webglAvailable;
}
const subscribeAvailability = () => () => {};
class SurfaceBoundary extends Component {
state = { failed: false };
static getDerivedStateFromError() { return { failed: true }; }
render() { return this.state.failed ? this.props.fallback : this.props.children; }
}
/** @param {{ children?: import("react").ReactNode, className?: string, style?: import("react").CSSProperties, imageSrc?: string, label?: string }} props */
export function WebGLSurface({ children, className, style, imageSrc, label = "ObsidianUI visual effect" }) {
const supported = useSyncExternalStore(subscribeAvailability, supportsWebGL, () => false);
const fallback = <div role="img" aria-label={label} className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: imageSrc ? `url(${JSON.stringify(imageSrc)})` : undefined }} />;
return (
<div className={cn("relative isolate h-[28rem] w-full overflow-hidden bg-black", className)} style={{ containerType: "size", ...style }}>
{fallback}
{supported && <SurfaceBoundary fallback={fallback}>{children}</SurfaceBoundary>}
</div>
);
}7
Add the supporting file
Copy into lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.
Preview behavior
Select a page or click the book to turn it. The effect stays inside its container and respects reduced motion preferences.
Last updated on