Playground

Interactive 3D Book

November 2025

The
Code Note
"use client";

import {motion, useMotionValue, useSpring, useTransform} from "motion/react";
import {useRef} from "react";

const RANGE = -180;
const PAGES_COUNT = 10;

const AnimatedBookPage = ({
    index,
    angle,
}: {
    index: number;
    angle: ReturnType<typeof useMotionValue<number>>;
}) => {
    const rotateY = useTransform(angle, (a) => a * index);

    return (
        <motion.div
            className="bg-background-medium-accent absolute top-0 left-1/2 grid aspect-4/5 h-full origin-left place-items-center rounded-r-xl border border-white/50 p-2 text-center text-balance will-change-transform transform-3d"
            style={{
                zIndex: PAGES_COUNT - 1 - index,
                rotateY,
            }}>
            {index === PAGES_COUNT - 1 && (
                <div className="font-sans text-xl backface-hidden">
                    The <br /> Code Note
                </div>
            )}
        </motion.div>
    );
};

const Interactive3dBook = () => {
    const containerRef = useRef<HTMLDivElement>(null);
    const angle = useSpring(-5, {
        mass: 0.1,
        damping: 16,
        stiffness: 71,
    });

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

        const rect = containerRef.current.getBoundingClientRect();
        const x = e.clientX - rect.left;

        const xPercentage = Math.max(0, Math.min(100, (x / rect.width) * 100));

        const totalAngle = ((100 - xPercentage) * RANGE) / 100;
        const offset = totalAngle / (PAGES_COUNT - 1);

        angle.set(offset);
    };

    const resetAngle = () => {
        angle.set(-5);
    };

    return (
        <article className="h-96 bg-white/5 px-0 sm:aspect-video sm:h-auto">
            <div
                className="relative size-full touch-none pt-6 pb-10"
                onPointerMove={handlePointerMove}
                onPointerOut={resetAngle}
                onPointerCancel={resetAngle}
                ref={containerRef}>
                <div className="relative size-full scale-60 rotate-4 perspective-[1500px] transform-3d lg:scale-80">
                    {Array.from({length: PAGES_COUNT}, (_, index) => (
                        <AnimatedBookPage
                            key={index}
                            index={index}
                            angle={angle}
                        />
                    ))}
                </div>
            </div>
        </article>
    );
};

export default Interactive3dBook;
Next

Dragging Slider