How to build a 3D tilt gallery grid in React with Framer Motion
A 3D tilt gallery in React wraps a CSS grid inside a perspective container, then reads the mouse position on mousemove to drive rotateX and rotateY via Framer Motion springs. The whole grid rotates together as a rigid plane, giving each card depth without per-card listeners.
- Stack: React 18+, Framer Motion 11, lucide-react for placeholder icons, ~170 lines total.
- Core APIs: useMotionValue, useSpring (stiffness 120, damping 20), CSS perspective: 1200px, transformStyle: preserve-3d.
- Cards enter the viewport via individual whileInView animations (z-axis fade, staggered 50ms delay per index).
- No per-card tilt logic: a single spring pair drives the entire grid plane, keeping the component lean.
- On touch devices the tilt simply stays at 0,0, no fallback needed, the layout remains fully functional.
Gallery 3D Wall is a responsive image grid that reacts to the mouse cursor by tilting the entire grid plane in 3D space. Rather than applying hover effects card by card, the whole layout pivots around a shared vanishing point, making the collection feel like a physical panel the user is examining from different angles. It suits portfolios, agency showcases, and any product page that needs an immediate sense of craft.
Anatomy
The component has three layers. An outer section tag handles full-width padding and background tokens. Inside sits a constrained container with a centered header (uppercase subtitle, large h2). Below it, a div marked as the perspective container holds a motion.div that acts as the 3D plane; this motion.div renders the CSS grid with auto-fill columns (minWidth 240px). Each cell is another motion.div with its own whileInView entrance, wrapping a fixed aspect-ratio card (4/3) with a background color, border radius, box shadow, and a placeholder icon centered inside.
How it works
On mousemove over the perspective container, the handler normalises the cursor position to a -0.5/+0.5 range relative to the container bounds using getBoundingClientRect. It then multiplies the horizontal offset by 8 to get the Y rotation angle and the vertical offset by -6 for the X rotation angle. Both values are pushed into Framer Motion springs (stiffness 120, damping 20, mass 0.8) so the grid follows the cursor with natural lag and bounce. On mouseleave both springs snap back to zero. The CSS perspective: 1200px on the wrapper and transformStyle: preserve-3d on the grid make the tilt visible; without preserve-3d the rotation has no visual depth.
How to build it in React
Set up the perspective wrapper and motion values
Create a container div with perspective: 1200px. Inside, render a motion.div with style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}. Wire rotateX and rotateY to useSpring instances so any changes animate smoothly.
const SPRING = { stiffness: 120, damping: 20, mass: 0.8 }; const rotateX = useSpring(useMotionValue(0), SPRING); const rotateY = useSpring(useMotionValue(0), SPRING);Read and normalise the mouse position
Attach onMouseMove to the perspective container. Subtract the container's bounding rect to get a local position, then divide by width/height to get values between 0 and 1. Shift by -0.5 so the centre is 0,0. Multiply by your rotation scale and push into the springs.
function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const cx = (e.clientX - rect.left) / rect.width - 0.5; const cy = (e.clientY - rect.top) / rect.height - 0.5; rotateY.set(cx * 8); rotateX.set(cy * -6); }Reset on mouse leave
Add an onMouseLeave handler that sets both springs back to 0. Framer Motion's spring will animate the return so the grid eases back rather than snapping, maintaining the physical feel.
function handleMouseLeave() { rotateX.set(0); rotateY.set(0); }Stagger card entrances with whileInView
Wrap each grid cell in a motion.div with initial={{ opacity: 0, z: -60, scale: 0.9 }} and whileInView={{ opacity: 1, z: 0, scale: 1 }}. Pass delay: index * 0.05 in the transition so cards cascade in rather than all appearing at once.
<motion.div initial={{ opacity: 0, z: -60, scale: 0.9 }} whileInView={{ opacity: 1, z: 0, scale: 1 }} viewport={{ once: true, margin: "-40px" }} transition={{ duration: 0.65, delay: i * 0.05, ease: EASE }} style={{ transformStyle: "preserve-3d" }} >
When to use it
This pattern shines on portfolio homepages, agency showcases, and product galleries where you want the grid itself to feel alive. Use it when the collection has 6 to 12 items, fewer looks sparse under the tilt effect, more makes the depth less readable. Skip it on e-commerce category pages where users scan dozens of items quickly; a static grid is faster to parse. On mobile the tilt does nothing, so ensure the static layout is strong on its own.
Used by
- Awwwards, Showcases agency portfolios that commonly feature 3D perspective grids as signature interactions on award-winning sites.
- Dribbble, Designer portfolio pages frequently use tilt-on-hover grid layouts to display shot collections with depth.
- Resend, Uses subtle 3D perspective shifts on card grids throughout its marketing site to add tactile character.
- Basement Studio, Agency site built around mouse-reactive 3D grid layouts as a core visual signature.
FAQ
Why does the tilt have no effect on mobile?
Touch screens fire touch events, not mousemove, so the springs never receive new values and stay at 0,0. The grid still renders correctly as a flat layout; you just lose the 3D rotation. If you want something on mobile, consider a touch-drag handler that reads the delta of a touchmove event.
Can I apply the tilt per card instead of the whole grid?
Yes. Move the useMotionValue and useSpring logic inside a card wrapper component and attach onMouseMove/onMouseLeave per card. The per-card approach is more CPU-intensive with many items but gives each card independent depth. For galleries over 8 cards, the whole-grid approach in this component is the safer choice.
What is the role of transformStyle: preserve-3d?
Without preserve-3d, child elements are flattened into the parent's plane; the rotation becomes a 2D skew that loses all depth. Setting transformStyle: preserve-3d on both the motion.div wrapper and each card div tells the browser to keep children in 3D space so the perspective camera sees actual depth between them.
How do I replace the placeholder cards with real images?
Add a src field to the GalleryItem interface and swap the placeholder div for a Next.js Image (or an img tag). Keep the aspect-ratio container and overflow: hidden, they maintain the card shape regardless of the image's natural dimensions.