Drag to flip pages with smooth 3D animations.
Drag to flip
Copy URL or Code
Paste to your AI coding assistant and say:
“Add this booklet component to my website”
Done. Your AI handles the rest.
Fully customizable. Ask your AI to change colors, content, size — tailor it to your needs. We built the hard part; you make it yours.
"use client";
import React, { useState, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
export interface BookletPage {
leftContent: React.ReactNode;
rightContent: React.ReactNode;
}
export interface BookletTheme {
primaryColor?: string;
bindingColor?: string;
pageColor?: string;
textureColor?: string;
showTexture?: boolean;
showPageNumbers?: boolean;
}
export interface BookletProps {
pages: BookletPage[];
theme?: BookletTheme;
maxWidth?: number;
className?: string;
title?: string;
subtitle?: string;
}
const defaultTheme: Required<BookletTheme> = {
primaryColor: "#703B3B",
bindingColor: "#703B3B",
pageColor: "#FFFBF5",
textureColor: "#e8e0d0",
showTexture: true,
showPageNumbers: true,
};
export default function Booklet({
pages,
theme: customTheme,
maxWidth = 600,
className = "",
title,
subtitle,
}: BookletProps) {
const theme = { ...defaultTheme, ...customTheme };
const [currentPage, setCurrentPage] = useState(0);
const [direction, setDirection] = useState(0);
const [isFlipping, setIsFlipping] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragProgress, setDragProgress] = useState(0);
const [dragSide, setDragSide] = useState<"left" | "right" | null>(null);
const [showFlipOverlay, setShowFlipOverlay] = useState(false);
const [frozenDragSide, setFrozenDragSide] = useState<"left" | "right" | null>(null);
const [disableAnimation, setDisableAnimation] = useState(false);
const bookRef = useRef<HTMLDivElement>(null);
const dragStartX = useRef(0);
const DRAG_THRESHOLD = 0.35;
const getTextureDataUrl = () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="${theme.pageColor}"/><rect width="1" height="1" fill="${theme.textureColor}"/></svg>`;
return `url('data:image/svg+xml;base64,${btoa(svg)}')`;
};
const handleDragStart = useCallback(
(e: React.MouseEvent | React.TouchEvent, side: "left" | "right") => {
if (side === "left" && currentPage === 0) return;
if (side === "right" && currentPage === pages.length - 1) return;
if (isFlipping || showFlipOverlay) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
dragStartX.current = clientX;
setDragSide(side);
setIsDragging(true);
setDragProgress(0);
},
[currentPage, pages.length, isFlipping, showFlipOverlay],
);
const handleDragMove = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (!isDragging || !bookRef.current || !dragSide) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const bookWidth = bookRef.current.offsetWidth;
const dragDistance = clientX - dragStartX.current;
let progress = 0;
if (dragSide === "right") {
progress = Math.max(0, Math.min(1, -dragDistance / (bookWidth * 0.5)));
} else {
progress = Math.max(0, Math.min(1, dragDistance / (bookWidth * 0.5)));
}
setDragProgress(progress);
},
[isDragging, dragSide],
);
const handleDragEnd = useCallback(() => {
if (!isDragging) return;
const shouldTurn = dragProgress >= DRAG_THRESHOLD;
if (shouldTurn) {
setDisableAnimation(true);
setFrozenDragSide(dragSide);
setShowFlipOverlay(true);
if (dragSide === "right") {
setDirection(1);
setCurrentPage((prev) => Math.min(prev + 1, pages.length - 1));
} else {
setDirection(-1);
setCurrentPage((prev) => Math.max(prev - 1, 0));
}
setIsDragging(false);
setDragProgress(0);
setDragSide(null);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setShowFlipOverlay(false);
setFrozenDragSide(null);
setTimeout(() => setDisableAnimation(false), 100);
});
});
} else {
setIsDragging(false);
setDragProgress(0);
setDragSide(null);
}
}, [isDragging, dragProgress, dragSide, pages.length]);
const getDragRotation = () => {
if (!isDragging) return 0;
return dragProgress * 180;
};
const getNextPageIndex = () => {
const side = dragSide || frozenDragSide;
if (side === "right") {
return Math.min(currentPage + 1, pages.length - 1);
} else {
return Math.max(currentPage - 1, 0);
}
};
const goToPage = (newPage: number) => {
if (newPage < 0 || newPage >= pages.length || isFlipping || showFlipOverlay || newPage === currentPage) return;
setIsFlipping(true);
setDirection(newPage > currentPage ? 1 : -1);
setCurrentPage(newPage);
setTimeout(() => setIsFlipping(false), 300);
};
const pageVariants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } };
const bindingDark = adjustColor(theme.bindingColor, -20);
const bindingLight = adjustColor(theme.bindingColor, 20);
return (
<div className={`w-full ${className}`}>
<div className="relative" style={{ perspective: "1500px" }}>
<div
ref={bookRef}
className="relative mx-auto select-none"
style={{
maxWidth: `${maxWidth}px`,
aspectRatio: "1.5 / 1",
transform: "rotateX(2deg)",
transformStyle: "preserve-3d",
backgroundColor: theme.pageColor,
boxShadow: `0 25px 50px -12px ${theme.primaryColor}4d, 0 12px 24px -8px rgba(0, 0, 0, 0.15), inset 0 0 0 1px ${theme.primaryColor}1a`,
borderRadius: "2px 4px 4px 2px",
}}
>
<div className="absolute left-0 top-0 bottom-0 w-[4px] pointer-events-none" style={{ background: `linear-gradient(to right, ${bindingDark}, ${theme.bindingColor} 40%, ${bindingLight})`, borderRadius: "2px 0 0 2px", boxShadow: "inset -1px 0 2px rgba(0,0,0,0.3)" }} />
<div className="absolute right-0 top-0 bottom-0 w-[4px] pointer-events-none" style={{ background: `linear-gradient(to left, ${bindingDark}, ${theme.bindingColor} 40%, ${bindingLight})`, borderRadius: "0 2px 2px 0", boxShadow: "inset 1px 0 2px rgba(0,0,0,0.3)" }} />
<div className="absolute left-0 right-0 top-0 h-[3px] pointer-events-none" style={{ background: `linear-gradient(to bottom, ${bindingDark}, ${theme.bindingColor})`, borderRadius: "2px 2px 0 0" }} />
<div className="absolute left-0 right-0 bottom-0 h-[3px] pointer-events-none" style={{ background: `linear-gradient(to top, ${adjustColor(theme.bindingColor, -30)}, ${theme.bindingColor})`, borderRadius: "0 0 2px 2px", boxShadow: "0 2px 4px rgba(0,0,0,0.2)" }} />
<div className="absolute left-[4px] top-[9px] bottom-[9px] w-[12px] pointer-events-none" style={{ background: `repeating-linear-gradient(to right, ${theme.pageColor} 0px, ${theme.textureColor} 1px, ${theme.pageColor} 2px, ${adjustColor(theme.textureColor, 5)} 3px, ${theme.pageColor} 4px)`, boxShadow: "inset -2px 0 4px rgba(0,0,0,0.12)" }} />
<div className="absolute right-[4px] top-[9px] bottom-[9px] w-[12px] pointer-events-none" style={{ background: `repeating-linear-gradient(to left, ${theme.pageColor} 0px, ${theme.textureColor} 1px, ${theme.pageColor} 2px, ${adjustColor(theme.textureColor, 5)} 3px, ${theme.pageColor} 4px)`, boxShadow: "inset 2px 0 4px rgba(0,0,0,0.12)" }} />
<div className="absolute top-[3px] left-[16px] right-[16px] h-[6px] pointer-events-none" style={{ background: `repeating-linear-gradient(to bottom, ${theme.pageColor} 0px, ${theme.textureColor} 1px, ${theme.pageColor} 2px, ${adjustColor(theme.textureColor, 5)} 3px, ${theme.pageColor} 4px)`, boxShadow: "inset 0 -2px 4px rgba(0,0,0,0.08)" }} />
<div className="absolute bottom-[3px] left-[16px] right-[16px] h-[6px] pointer-events-none" style={{ background: `repeating-linear-gradient(to top, ${theme.pageColor} 0px, ${theme.textureColor} 1px, ${theme.pageColor} 2px, ${adjustColor(theme.textureColor, 5)} 3px, ${theme.pageColor} 4px)`, boxShadow: "inset 0 2px 4px rgba(0,0,0,0.08)" }} />
<div className="absolute left-1/2 top-0 bottom-0 w-[6px] -translate-x-1/2 z-10 pointer-events-none" style={{ background: "linear-gradient(to right, rgba(0,0,0,0.06), rgba(0,0,0,0.1) 50%, rgba(0,0,0,0.06))" }} />
<div className="absolute left-1/2 top-0 bottom-0 w-[1px] -translate-x-1/2 z-10 pointer-events-none" style={{ backgroundColor: adjustColor(theme.textureColor, -10) }} />
<div className="absolute left-[16px] top-[9px] bottom-[9px] w-4 pointer-events-none z-[5]" style={{ background: "linear-gradient(to right, rgba(0,0,0,0.04), transparent)" }} />
<div className="absolute right-[16px] top-[9px] bottom-[9px] w-4 pointer-events-none z-[5]" style={{ background: "linear-gradient(to left, rgba(0,0,0,0.04), transparent)" }} />
<div className="absolute inset-0 overflow-hidden" style={{ margin: "9px 16px 9px 16px" }}>
{(() => {
const nextPage = getNextPageIndex();
const leftPageIndex = isDragging && dragSide === "left" && dragProgress > 0 ? nextPage : currentPage;
const rightPageIndex = isDragging && dragSide === "right" && dragProgress > 0 ? nextPage : currentPage;
const renderPageContent = (pageIndex: number, side: "left" | "right") => (
<div className={`relative p-4 md:p-6 flex ${side === "left" ? "flex-col justify-center border-r" : "items-center justify-center"}`} style={{ borderColor: `${theme.primaryColor}1a` }}>
{theme.showTexture && <div className="absolute inset-0 opacity-30 pointer-events-none" style={{ backgroundImage: getTextureDataUrl() }} />}
<div className="relative z-10 w-full h-full flex items-center justify-center">
{side === "left" ? pages[pageIndex]?.leftContent : pages[pageIndex]?.rightContent}
</div>
{theme.showPageNumbers && (
<span className={`absolute bottom-2 ${side === "left" ? "left-4" : "right-4"} font-mono text-[10px]`} style={{ color: `${theme.primaryColor}66` }}>
{pageIndex * 2 + (side === "left" ? 1 : 2)}
</span>
)}
</div>
);
return disableAnimation || isDragging ? (
<div className="absolute inset-0 grid grid-cols-2">
{renderPageContent(leftPageIndex, "left")}
{renderPageContent(rightPageIndex, "right")}
</div>
) : (
<AnimatePresence mode="wait">
<motion.div key={currentPage} variants={pageVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }} className="absolute inset-0 grid grid-cols-2">
{renderPageContent(currentPage, "left")}
{renderPageContent(currentPage, "right")}
</motion.div>
</AnimatePresence>
);
})()}
{isDragging && dragProgress > 0 && (
<div className="absolute inset-0 z-30 pointer-events-none overflow-hidden" style={{ perspective: "2000px" }}>
<div className="absolute" style={{ left: dragSide === "right" ? "50%" : 0, right: dragSide === "left" ? "50%" : 0, top: 0, bottom: 0, transformStyle: "preserve-3d", transformOrigin: dragSide === "right" ? "left center" : "right center", transform: `rotateY(${dragSide === "right" ? -getDragRotation() : getDragRotation()}deg)` }}>
<div className="absolute inset-0" style={{ backgroundColor: theme.pageColor, backfaceVisibility: "hidden", boxShadow: `-5px 0 20px rgba(0,0,0,${0.1 + dragProgress * 0.15})` }}>
{theme.showTexture && <div className="absolute inset-0 opacity-30" style={{ backgroundImage: getTextureDataUrl() }} />}
<div className="absolute inset-0 p-4 md:p-6 flex items-center justify-center">
{dragSide === "right" ? pages[currentPage]?.rightContent : pages[currentPage]?.leftContent}
</div>
</div>
<div className="absolute inset-0" style={{ backgroundColor: theme.pageColor, backfaceVisibility: "hidden", transform: "rotateY(180deg)", boxShadow: `5px 0 20px rgba(0,0,0,${0.1 + dragProgress * 0.15})` }}>
{theme.showTexture && <div className="absolute inset-0 opacity-30" style={{ backgroundImage: getTextureDataUrl() }} />}
<div className="absolute inset-0 p-4 md:p-6 flex items-center justify-center">
{dragSide === "right" ? pages[getNextPageIndex()]?.leftContent : pages[getNextPageIndex()]?.rightContent}
</div>
</div>
</div>
</div>
)}
{showFlipOverlay && frozenDragSide && (
<div className="absolute inset-0 z-30 pointer-events-none" style={{ backgroundColor: theme.pageColor }}>
<div className="absolute inset-0 grid grid-cols-2">
<div className="relative p-4 md:p-6 flex flex-col justify-center border-r" style={{ borderColor: `${theme.primaryColor}1a` }}>
{theme.showTexture && <div className="absolute inset-0 opacity-30" style={{ backgroundImage: getTextureDataUrl() }} />}
<div className="relative z-10">{pages[currentPage]?.leftContent}</div>
</div>
<div className="relative p-4 md:p-6 flex items-center justify-center">
{theme.showTexture && <div className="absolute inset-0 opacity-30" style={{ backgroundImage: getTextureDataUrl() }} />}
<div className="relative z-10">{pages[currentPage]?.rightContent}</div>
</div>
</div>
</div>
)}
</div>
</div>
{isDragging ? (
<div className="absolute inset-0 z-[100] cursor-grabbing" style={{ top: 0, left: 0, right: 0, bottom: "auto", height: "calc(100% - 60px)" }} onMouseMove={handleDragMove} onMouseUp={handleDragEnd} onMouseLeave={handleDragEnd} onTouchMove={handleDragMove} onTouchEnd={handleDragEnd} />
) : (
<>
{currentPage > 0 && (
<div onMouseDown={(e) => handleDragStart(e, "left")} onTouchStart={(e) => handleDragStart(e, "left")} className="absolute left-0 top-0 w-1/2 z-[100] cursor-grab group" style={{ height: "calc(100% - 60px)" }} aria-label="Drag right to go to previous page">
<div className="absolute left-6 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-60 transition-all duration-300">
<svg className="w-5 h-5" style={{ color: theme.primaryColor }} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 19l-7-7 7-7" /></svg>
</div>
</div>
)}
{currentPage < pages.length - 1 && (
<div onMouseDown={(e) => handleDragStart(e, "right")} onTouchStart={(e) => handleDragStart(e, "right")} className="absolute right-0 top-0 w-1/2 z-[100] cursor-grab group" style={{ height: "calc(100% - 60px)" }} aria-label="Drag left to go to next page">
<div className="absolute right-6 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-60 transition-all duration-300">
<svg className="w-5 h-5" style={{ color: theme.primaryColor }} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5l7 7-7 7" /></svg>
</div>
</div>
)}
</>
)}
<div className="flex justify-center gap-1.5 mt-4">
{pages.map((_, idx) => (
<button key={idx} onClick={() => goToPage(idx)} className={`h-1.5 rounded-full transition-all duration-300 ${idx === currentPage ? "w-4" : "w-1.5 hover:opacity-70"}`} style={{ backgroundColor: idx === currentPage ? theme.primaryColor : `${theme.primaryColor}4d` }} aria-label={`Go to page ${idx + 1}`} />
))}
</div>
{(title || subtitle) && (
<div className="text-center mt-4">
{title && <h3 className="text-xl md:text-2xl font-semibold" style={{ color: theme.primaryColor }}>{title}</h3>}
{subtitle && <p className="text-xs mt-1 opacity-70" style={{ color: theme.primaryColor }}>{subtitle}</p>}
</div>
)}
</div>
</div>
);
}
function adjustColor(color: string, amount: number): string {
const hex = color.replace("#", "");
const num = parseInt(hex, 16);
const r = Math.min(255, Math.max(0, (num >> 16) + amount));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amount));
const b = Math.min(255, Math.max(0, (num & 0x0000ff) + amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
}import Booklet from "@/components/Booklet";
const pages = [
{
leftContent: <div><h3>Page 1</h3><p>Content...</p></div>,
rightContent: <img src="/image.png" alt="Image" />,
},
];
<Booklet pages={pages} maxWidth={600} title="My Booklet" />