3D Book

3d-book.tsx
"use client";

import { useRef, useState } from "react";

export default function InteractiveBook() {
  const bookRef = useRef<HTMLDivElement>(null);
  const [progress, setProgress] = useState(0);
  const [isDragging, setIsDragging] = useState(false);

  // Linear interpolation function
  const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!bookRef.current) return;

    const rect = bookRef.current.getBoundingClientRect();
    const cursorX = e.clientX;
    const bookCenterX = rect.left + rect.width / 2;

    // Calculate how far cursor is from center (-1 to 1)
    const distanceFromCenter = (cursorX - bookCenterX) / (rect.width / 2);

    // Map cursor position to progress
    // Left side (negative) = open (1), Right side (positive) = closed (0)
    const targetProgress = lerp(1, 0, (distanceFromCenter + 1) / 2);

    // Clamp between 0 and 1
    setProgress(Math.max(0, Math.min(1, targetProgress)));
  };

  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    setIsDragging(true);
    handlePointerMove(e);
  };

  const handlePointerUp = () => {
    setIsDragging(false);
    setProgress(0);
  };

  const handlePointerLeave = () => {
    // Close book when cursor leaves (only if not dragging on mobile)
    if (!isDragging) {
      setProgress(0);
    }
  };

  // Generate pages - all at same size and position
  const totalPages = 15;
  const pages = [];

  // All pages same size, different rotation angles
  for (let i = 1; i <= totalPages; i++) {
    const rotationAngle = (i + 1) * 10; // -20, -30, -40... -160

    pages.push(
      <div
        key={i}
        className="absolute h-48 md:h-72 w-32 md:w-52 rounded-lg md:rounded-2xl border border-border bg-background"
        style={
          {
            transformStyle: "preserve-3d",
            transformOrigin: "left",
            transform: `rotateY(calc(var(--book-progress) * ${-rotationAngle}deg))`,
            zIndex: 50 + i,
            backfaceVisibility: "visible",
            "--book-progress": progress,
          } as React.CSSProperties
        }
      />
    );
  }

  return (
    <div className="flex w-full h-full items-center justify-center  ">
      <div
        ref={bookRef}
        className="w-32 md:w-52 h-48 md:h-72 will-change-transform translate-x-16 md:translate-x-24 touch-none"
        onPointerMove={handlePointerMove}
        onPointerDown={handlePointerDown}
        onPointerUp={handlePointerUp}
        onPointerLeave={handlePointerLeave}
        style={
          {
            perspective: "1500px",
            transformStyle: "preserve-3d",
            "--book-progress": progress,
          } as React.CSSProperties
        }
      >
        {/* Back Cover (underneath all pages) */}
        <div
          className="absolute h-48 md:h-72 w-32 md:w-52 rounded-lg md:rounded-2xl border-2 border-border"
          style={{
            transformStyle: "preserve-3d",
            transformOrigin: "left",
            background:
              "radial-gradient(hsl(var(--muted)) 0 1px, hsl(var(--background)) 1px 100%) 0 0 / 4px 4px",
            boxShadow: "0 5px 15px rgba(0,0,0,0.1)",
            zIndex: 1,
          }}
        />

        {/* Pages - all same size, different rotations */}
        {pages}

        {/* Front Cover */}
        <div
          className="absolute h-48 md:h-72 w-32 md:w-52 bg-muted overflow-hidden"
          style={
            {
              transformStyle: "preserve-3d",
              transformOrigin: "left center",
              transform: `rotateY(calc(var(--book-progress) * -165deg))`,
              boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
              borderRadius: "0 8px 8px 0",
              zIndex: 200,
              "--book-progress": progress,
            } as React.CSSProperties
          }
        >
          {/* Cover shadow overlay */}
          <div
            className="absolute inset-0 pointer-events-none z-30"
            style={{
              borderRadius: "0 8px 8px 0",
              boxShadow:
                "0 0 0 0.85px rgba(0, 0, 0, 0.1) inset, 2px 0 1px 0 rgba(0, 0, 0, 0.1) inset, -1.5px 0 1px 0 rgba(0, 0, 0, 0.1) inset, 0 2px 2px 0 rgba(255, 255, 255, 0.1) inset, 0 8px 16px 0 rgba(0, 0, 0, 0.05)",
            }}
          />

          {/* Red top section */}
          <div
            className="absolute top-0 left-0 right-0 h-[40%] z-10 p-1.5 md:p-3 pl-2 md:pl-4"
            style={{
              backgroundColor: "rgb(187, 1, 58)",
            }}
          />

          {/* Spine edge */}
          <div className="absolute top-0 left-0 bottom-0 w-2 md:w-3.5 z-30 flex flex-row justify-end">
            <div className="w-0.5 h-full bg-background/25" />
            <div className="w-0.5 h-full bg-foreground/15" />
          </div>

          {/* Title */}
          <div
            className="absolute bottom-1.5 left-3 md:left-6 right-1.5 text-sm md:text-2xl font-medium pointer-events-none select-none z-20 text-muted-foreground/30"
            style={{
              textShadow: "0 0 2px hsl(var(--background))",
              backfaceVisibility: "hidden",
            }}
          >
            Notebook
          </div>

          {/* Back label */}
          <div
            className="absolute top-1/2 right-1/2 text-xs md:text-base font-semibold text-center text-primary"
            style={{
              transform: "translate(50%, -50%) rotateY(180deg) scaleX(-1)",
              backfaceVisibility: "hidden",
            }}
          >
            Back Page
          </div>
        </div>
      </div>
    </div>
  );
}

Installation

npx shadcn@latest add "https://uselayouts.com/r/3d-book.json"

Copy the code

Copy the code from the Code tab above into components/interactive-book.tsx.

Update imports

Update the imports to match your project structure.

Usage

import InteractiveBook from "@/components/3d-book";

export default function Page() {
  return (
    <div className="h-[500px] w-full">
      <InteractiveBook />
    </div>
  );
}

Features

  • 3D Perspective: Realistic book cover and page depth using CSS 3D transforms.
  • Interactive: Book opens responsively on hover or drag interaction.
  • Performance: Pure CSS/JS implementation without heavy WebGL dependencies.
  • Responsive: Adapts to container size (desktop and mobile friendly).
  • Customizable: Easy to swap cover art or page content.