Back to Blog

3D Tilt Card That Follows Your Cursor in React (R3F)

Celest KimCelest Kim

Video: 3D Tilt Card That Follows Your Cursor in React (R3F) by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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 Canvas that sets up the renderer, scene, and camera for us.
  • A card built from drei’s RoundedBox primitives wrapped in a group.
  • A small icon spun every frame with useFrame.
  • Float for a gentle idle drift, and PresentationControls for 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, and PresentationControls come 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

  1. PresentationControls is the entire interaction. One component turns pointer movement into a spring-damped 3D tilt — no event handlers, no math, just limits and a spring.
  2. drei gives you production-ready primitives. RoundedBox and Float mean rounded corners and idle motion for free, so you spend your time on the look, not the geometry.
  3. useFrame is the animation loop. Spin a ref a little every frame, scaled by delta, 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.