EmpireUI
Get Pro
← Blog8 min read#tilt#3d#card

3D Tilt Card in React: Gyroscope-Style Perspective on Mouse Move

Build a gyroscope-style 3D tilt card in React using mouse move events and CSS perspective transforms — no library required.

3D tilt card with perspective transform on dark background

What You're Actually Building

You've seen them everywhere — cards that rotate slightly as your mouse passes over them, like they're sitting on a gyroscope. The effect makes flat UIs feel physical. Apple uses it. Vercel uses it. And the math behind it is genuinely simple once you stop overthinking it.

The core idea: track where the mouse is relative to the card's bounding box, map that position to a rotation value, and apply rotateX and rotateY via CSS transforms. That's it. No physics engine, no canvas, no WebGL. Just onMouseMove, a ref, and a bit of trigonometry.

That said, the details matter. Easing, transform origin, perspective depth, glare overlays — these are what separate a card that feels good from one that makes people mildly nauseous. We'll cover all of it.

Quick aside: if you want to explore how CSS 3D transforms behave at a lower level, the css-3d-transforms article on the blog is a solid primer before continuing here.

The Math: Mouse Position to Rotation Angle

First, grab the card's position in the viewport with getBoundingClientRect(). Then compute where the mouse sits within that rectangle as a value between -1 and 1. That normalized value maps directly to your max rotation angle — say, 15 degrees.

Here's the formula. If the card is 300px wide and the mouse is 75px from the left edge, the normalized X is (75 / 300) * 2 - 1 = -0.5. Multiply by 15 and you get -7.5deg of Y-axis rotation. Same logic applies vertically for X-axis rotation — just remember the axis is inverted (mouse up → card tilts back, so rotateX is negative).

function getRotation(e, card) {
  const rect = card.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;
  const normX = (x / rect.width) * 2 - 1;   // -1 to 1
  const normY = (y / rect.height) * 2 - 1;  // -1 to 1
  return {
    rotateX: -normY * 15,  // invert Y
    rotateY: normX * 15,
  };
}

Worth noting: the 15-degree max is a deliberate choice. Go above 20 degrees and the card starts to look broken, especially on content-heavy cards. Stay under 10 and the effect is barely noticeable. 12–18 is the sweet spot for most UI contexts.

In practice, you'll also want to clamp these values if the mouse leaves the element mid-drag. Math.min(Math.max(val, -MAX), MAX) saves you from weird edge cases when the browser fires one last mousemove outside the element bounds.

Building the Component from Scratch

Here's a full, self-contained React component. No external library. It uses useRef for the card element and useState for the current transform, plus a spring-style lerp on mouseleave to snap back smoothly.

import { useRef, useState, useCallback } from 'react';

const MAX_TILT = 15;
const PERSPECTIVE = 1000;

export function TiltCard({ children }: { children: React.ReactNode }) {
  const cardRef = useRef<HTMLDivElement>(null);
  const [transform, setTransform] = useState('rotateX(0deg) rotateY(0deg)');
  const [glare, setGlare] = useState({ x: 50, y: 50, opacity: 0 });
  const rafRef = useRef<number>(0);

  const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
    const card = cardRef.current;
    if (!card) return;

    cancelAnimationFrame(rafRef.current);
    rafRef.current = requestAnimationFrame(() => {
      const rect = card.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const normX = (x / rect.width) * 2 - 1;
      const normY = (y / rect.height) * 2 - 1;
      const rX = -normY * MAX_TILT;
      const rY = normX * MAX_TILT;

      setTransform(`rotateX(${rX}deg) rotateY(${rY}deg)`);
      setGlare({
        x: (x / rect.width) * 100,
        y: (y / rect.height) * 100,
        opacity: 0.15,
      });
    });
  }, []);

  const handleMouseLeave = useCallback(() => {
    cancelAnimationFrame(rafRef.current);
    setTransform('rotateX(0deg) rotateY(0deg)');
    setGlare(prev => ({ ...prev, opacity: 0 }));
  }, []);

  return (
    <div style={{ perspective: `${PERSPECTIVE}px` }}>
      <div
        ref={cardRef}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        style={{
          transform,
          transition: 'transform 0.1s ease-out',
          transformStyle: 'preserve-3d',
          position: 'relative',
          borderRadius: '16px',
          overflow: 'hidden',
        }}
      >
        {children}
        {/* glare layer */}
        <div
          aria-hidden
          style={{
            position: 'absolute',
            inset: 0,
            pointerEvents: 'none',
            background: `radial-gradient(circle at ${glare.x}% ${glare.y}%, rgba(255,255,255,${glare.opacity}), transparent 70%)`,
            transition: 'opacity 0.2s',
            mixBlendMode: 'overlay',
          }}
        />
      </div>
    </div>
  );
}

The perspective: 1000px wrapper is critical — without it you get no depth perception at all. Lower values (like 400px) make the tilt feel extreme and distorted. 800–1200px is the comfortable range for card-sized elements.

Notice the requestAnimationFrame throttle. Mousemove fires at up to 1000Hz on some mice. You don't need that. rAF caps your work at the display refresh rate — typically 60 or 120fps — which is more than enough.

The glare overlay is optional but it adds a lot. It's a radial gradient that follows the mouse, simulating light reflection. Using mixBlendMode: 'overlay' means it plays nicely with both dark and light card backgrounds without you hardcoding a color.

Adding a CSS Transition vs. a Spring Animation

The transition: 'transform 0.1s ease-out' in the component above works fine for the reset-on-leave behavior, but during active mouse movement you want zero delay — or the card lags behind the cursor and feels wrong. That's why you'd conditionally remove the transition during movement and add it back on mouseleave.

const [isHovering, setIsHovering] = useState(false);

// In the element style:
style={{
  transform,
  transition: isHovering ? 'none' : 'transform 0.4s cubic-bezier(0.23, 1, 0.32, 1)',
  // ...
}}

// Handlers:
const handleMouseEnter = () => setIsHovering(true);
const handleMouseLeave = () => {
  setIsHovering(false);
  setTransform('rotateX(0deg) rotateY(0deg)');
};

That cubic-bezier (0.23, 1, 0.32, 1) is a snappy spring that overshoots just slightly on the return. You can dial it in with the gradient generator tool's easing presets, or just play with it manually. The key is the return animation takes ~400ms — fast enough to feel responsive, slow enough to feel physical.

If you're already using Framer Motion in your project, useSpring and useMotionValue give you a smoother result with built-in damping. But honestly, for a pure CSS approach the transition swap technique above is nearly identical in perceived quality and adds zero bytes to your bundle.

Look, don't reach for a library just because it exists. The vanilla version here is 60 lines and has no peer dependency footprint. For most projects, that's the right call.

Gyroscope Support for Mobile

Mouse events don't exist on touch devices. But the DeviceOrientation API does — and on iOS and Android, it gives you actual gyroscope data you can map to the same tilt effect. It's not widely used, but when you do it right, the result on mobile is genuinely impressive.

useEffect(() => {
  const handleOrientation = (e: DeviceOrientationEvent) => {
    const beta = e.beta ?? 0;   // -180 to 180, front-back tilt
    const gamma = e.gamma ?? 0; // -90 to 90, left-right tilt

    const rX = Math.min(Math.max(beta * 0.3, -MAX_TILT), MAX_TILT);
    const rY = Math.min(Math.max(gamma * 0.3, -MAX_TILT), MAX_TILT);
    setTransform(`rotateX(${rX}deg) rotateY(${rY}deg)`);
  };

  window.addEventListener('deviceorientation', handleOrientation);
  return () => window.removeEventListener('deviceorientation', handleOrientation);
}, []);

One more thing — iOS 13+ requires a user gesture to grant DeviceOrientation permission. You'll need to call DeviceOrientationEvent.requestPermission() on a button click before attaching the listener. Skip this and the event just silently does nothing on iPhones.

The 0.3 multiplier is worth tuning per device. Raw gyroscope values swing hard — beta can go from 0 to 90 just by tilting the phone naturally. Scaling down to 30% of the raw value keeps the tilt within the 15-degree range that looks intentional rather than broken.

Worth noting: you can detect touch support with 'ontouchstart' in window and swap between the mouse and gyroscope listeners accordingly, giving a unified effect across devices from a single component.

Applying It to Glassmorphism and Dark UI Cards

The tilt effect pairs best with cards that have visible depth cues — things like shadows, borders, or translucency. A flat white card on a white background tilting slightly registers almost nothing visually. Put the same tilt on a glassmorphism component with a backdrop blur and it suddenly looks like a physical sheet of frosted glass.

.glass-card {
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
              0 0 0 0.5px rgba(255, 255, 255, 0.05) inset;
  border-radius: 16px;
  padding: 24px;
}

That box-shadow with the inset 0.5px edge highlight is the secret sauce. When the card tilts, that subtle inner highlight shifts in perceived position due to the perspective transform, reinforcing the 3D illusion without any extra JavaScript.

For neobrutalism-style cards (heavy borders, high contrast), tilt feels weird unless you strip the box shadow entirely and lean into translate3d for a more planar feel. Check out what's available in the neobrutalism style hub if you want reference implementations.

Honestly, the tilt card shines brightest in dark UIs with gradient or aurora backgrounds. The depth contrast is higher, the glare overlay pops more, and users actually notice it. On a fully lit white background it reads as a subtle flourish at best.

Performance, Accessibility, and When Not to Use It

The transform + rAF approach composites on the GPU, so performance is generally fine. What you want to avoid is triggering layout recalculation inside the mousemove handler — never read or write width, height, offsetTop, or anything that causes a style recalculation inside the handler itself. getBoundingClientRect() called once in mouseenter and cached is fine; calling it on every mousemove event is not.

// Cache the rect on enter, not on every move
const rectRef = useRef<DOMRect | null>(null);

const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
  rectRef.current = e.currentTarget.getBoundingClientRect();
};

const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
  const rect = rectRef.current;
  if (!rect) return;
  // use cached rect
};

Accessibility-wise, respect prefers-reduced-motion. Users who opt into reduced motion have likely done so for vertigo or vestibular reasons. A spinning, tilting card is exactly the kind of thing that causes discomfort. Wrap your transform logic in a media query check.

const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

// Skip all tilt logic if true
if (prefersReducedMotion) return;

One more thing — don't tilt cards that contain form inputs, video players, or interactive elements where precise pointer targeting matters. The geometric distortion can make it genuinely hard to click small targets at high tilt angles. Save this effect for hero cards, feature showcases, pricing tiers, and similar display-only contexts. Browse the Empire UI component library for ready-made card variants that already make these distinctions.

FAQ

Does the tilt effect work on touch screens?

Mouse events don't fire on touch devices, but you can use the DeviceOrientation API with gyroscope data to achieve the same effect. On iOS 13+, you need to call DeviceOrientationEvent.requestPermission() via a user gesture first.

Why does my card tilt feel laggy or stuttery?

You're probably reading getBoundingClientRect() on every mousemove event — cache it in mouseenter instead. Also make sure you're wrapping the transform update in requestAnimationFrame to cap updates at the display refresh rate.

What's the right CSS perspective value for a tilt card?

For card-sized elements, 800–1200px perspective gives realistic depth without distortion. Values below 400px make the card look exaggerated and vertiginous at even small rotation angles.

Should I use a library like vanilla-tilt or react-tilt instead?

Only if you need advanced features like gyroscope support, scale-on-hover, and glare built in as config options. For basic tilt, the native approach in this article is about 60 lines with zero dependencies.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

User Profile Card in React: Avatar, Stats, Follow Button, BioSpotlight Card Effect in React: Cursor-Tracking Glow on HoverClaymorphism Card Components: 3D Puffy Cards With Soft Shadows3D Card Effect in Tailwind: [perspective] and rotate3d Utilities