3D Tilt Card That Follows Your Cursor in React (R3F)
Video: 3D Tilt Card That Follows Your Cursor in React (R3F) by CelesteAI
Open any polished portfolio or SaaS landing page and you will eventually find it: a card that leans toward your cursor in 3D, catching the light as it moves, then springing gently back when you look away. It feels like the kind of thing that needs a physics engine and a wall of pointer-event math. In React, it does not. With React Three Fiber — the React renderer for Three.js — and its companion helper pack drei, the whole interaction is a single component, and the card is about ninety lines of TypeScript.
A sunflower turns to follow the sun all day. This card does the same thing with your cursor, and you write almost none of the tracking yourself.
In this build we make a floating profile card: a rounded slab with a header band, an avatar, two lines of text, and a small 3D icon hovering below — lit, casting a soft shadow, tilting toward the pointer and snapping back to center on release.
What we are building
- A React + TypeScript project scaffolded with Vite.
- A
Canvasthat sets up the renderer, scene, and camera for us. - A card built from drei’s
RoundedBoxprimitives wrapped in a group. - A small icon spun every frame with
useFrame. Floatfor a gentle idle drift, andPresentationControlsfor the spring-damped tilt.- Lights, a ground plane, and a soft shadow.
1. Scaffold the project
The same flow as any real React app. Scaffold with Vite using the react-ts template, because the whole scene is written as React components.
npm create vite@latest tilt-card-r3f -- --template react-ts
cd tilt-card-r3f
npm install
npm install three @react-three/fiber @react-three/drei @types/three
Three libraries do the work:
- three — the actual 3D engine.
- @react-three/fiber — the bridge that renders Three.js through React, so a mesh is a component and the scene is JSX.
- @react-three/drei — a pack of ready-made helpers. It is where
RoundedBox,Float, andPresentationControlscome from, so you do not write geometry math or pointer handlers yourself.
2. The HTML shell
Vite scaffolds an index.html. Trim it to a single full-window root div. There is no canvas tag — fiber creates and manages the canvas for you.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>3D Tilt Card in React</title>
<style>
html, body, #root { margin: 0; height: 100%; }
body { background: #0b1020; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
3. The card: rounded boxes in a group
Open src/main.tsx and clear it. The card is one component. Each part is a mesh, and the rounded panels come straight from drei’s RoundedBox, which takes width, height, depth, a corner radius, and a smoothness — no manual geometry. Everything lives in one group so the whole card moves and tilts as a single unit.
import { useRef } from "react";
import { createRoot } from "react-dom/client";
import { Canvas, useFrame } from "@react-three/fiber";
import { PresentationControls, Float, RoundedBox } from "@react-three/drei";
import * as THREE from "three";
function Card() {
const icon = useRef<THREE.Mesh>(null);
// useFrame runs once every rendered frame. We turn the little floating icon a
// touch each frame, scaled by delta, so it spins at the same speed anywhere.
useFrame((_, delta) => {
if (icon.current) icon.current.rotation.y += delta * 0.8;
});
return (
<group>
{/* the card body - rounded corners come free from drei RoundedBox */}
<RoundedBox args={[3.2, 4.2, 0.25]} radius={0.16} smoothness={6} castShadow>
<meshStandardMaterial color="#f4f6fb" roughness={0.45} metalness={0.1} />
</RoundedBox>
{/* a cyan header band sitting just in front of the surface */}
<RoundedBox args={[3.2, 1.2, 0.26]} radius={0.16} smoothness={6} position={[0, 1.5, 0.01]}>
<meshStandardMaterial color="#61dafb" roughness={0.4} />
</RoundedBox>
{/* a sphere raised off the card face as the avatar */}
<mesh position={[0, 1.1, 0.25]} castShadow>
<sphereGeometry args={[0.55, 48, 48]} />
<meshStandardMaterial color="#1b2540" roughness={0.3} metalness={0.4} />
</mesh>
{/* two thin rounded bars standing in for lines of text */}
{[-0.3, -0.8].map((y, i) => (
<RoundedBox key={i} args={[i === 0 ? 2.2 : 1.6, 0.2, 0.22]} radius={0.09} smoothness={4} position={[0, y, 0.02]}>
<meshStandardMaterial color="#cdd7e6" roughness={0.6} />
</RoundedBox>
))}
{/* a torus knot that floats below the card as a small 3D icon */}
<mesh ref={icon} position={[0, -1.55, 0.5]} castShadow>
<torusKnotGeometry args={[0.32, 0.12, 128, 24]} />
<meshStandardMaterial color="#e5484d" roughness={0.25} metalness={0.5} />
</mesh>
</group>
);
}
Two things worth noticing:
- The panels are RoundedBox, not box. drei hands you rounded corners as an argument, so the card looks like a real UI surface without any geometry work.
- The text lines are a loop. Mapping over an array of y-positions builds the two bars from one block, the same pattern you would use for a real list of fields.
4. The spin: useFrame
The little icon spins with the same one-liner the whole series leans on. useFrame is fiber’s render loop: the callback runs every frame, and its second argument delta is the seconds since the last frame. Multiplying by delta makes the spin frame-rate independent — identical on a 60Hz laptop and a 120Hz monitor.
useFrame((_, delta) => {
if (icon.current) icon.current.rotation.y += delta * 0.8;
});
5. The scene and the tilt: Canvas + PresentationControls
The Canvas builds the WebGL renderer, the scene, and the camera in one tag; everything inside it is the scene graph. The interaction is the interesting part. PresentationControls wraps the card and turns pointer movement into a smooth, spring-damped tilt — you give it polar and azimuth limits, a spring config, and snap so it eases back to center on release. Float nests inside it to add a slow idle drift, so the card is alive even when the cursor is still.
function App() {
return (
<Canvas shadows camera={{ position: [0, 0, 7], fov: 40 }}>
<color attach="background" args={["#0b1020"]} />
<ambientLight intensity={0.6} color="#bcd3ff" />
<directionalLight position={[4, 6, 5]} intensity={1.6} castShadow />
<directionalLight position={[-5, 2, -4]} intensity={0.6} color="#61dafb" />
<PresentationControls
global
polar={[-0.4, 0.4]}
azimuth={[-0.6, 0.6]}
config={{ mass: 1, tension: 220, friction: 18 }}
snap
>
<Float speed={2} rotationIntensity={0.4} floatIntensity={0.6}>
<Card />
</Float>
</PresentationControls>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -2.4, 0]} receiveShadow>
<planeGeometry args={[60, 60]} />
<meshStandardMaterial color="#141b30" />
</mesh>
</Canvas>
);
}
createRoot(document.getElementById("root")!).render(<App />);
The props on PresentationControls are worth a second:
global— the whole canvas drives the tilt, not just clicks that land on the card.polar/azimuth— how far it can lean up-down and left-right, in radians.config— the spring (mass, tension, friction); raise the tension for a snappier follow.snap— return to center when the pointer is released, instead of holding the last angle.
A soft shadow needs three flags on three objects, and missing any one means nothing appears: shadows on the Canvas, castShadow on the light and the card, and receiveShadow on the ground.
6. Run it
npm run dev
Open the URL Vite prints and move your mouse across the page. The card leans toward the cursor, the icon keeps spinning, and the whole thing eases back to center when you stop.
Make it yours
The card is built from primitives so it stays in one file, but the structure is exactly what you would use in production. Swap the avatar sphere for an image texture, replace the bars with real Text from drei, or drop a loaded .glb where the icon is — the PresentationControls tilt and the Float drift keep working untouched. That is the whole appeal: once the interaction is in place, the contents are the only moving part.
Takeaways
PresentationControlsis the entire interaction. One component turns pointer movement into a spring-damped 3D tilt — no event handlers, no math, just limits and a spring.- drei gives you production-ready primitives.
RoundedBoxandFloatmean rounded corners and idle motion for free, so you spend your time on the look, not the geometry. useFrameis the animation loop. Spin a ref a little every frame, scaled bydelta, and the motion is smooth and frame-rate independent on any monitor.
Full source is on GitHub. This is part of the Build It in the Browser series on Codegiz — AI-produced, human-reviewed.