The
Code Note
Code Note
November 2025
"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;