EmpireUI
Get Pro
← Blog8 min read#3d card#css#react

3D Card Effect in React: perspective, rotateX/Y and Mouse Tracking

Build a real 3D tilt card in React using CSS perspective, rotateX/Y transforms and live mouse tracking — no library required, just 60 lines of code.

floating 3D card with geometric depth and light reflection on dark background

Why CSS 3D Still Beats Canvas for Card Effects

People keep reaching for Three.js or canvas when they want a tilt effect. Don't. CSS 3D transforms have been GPU-accelerated since Chrome 36 (2014), and for a card that rotates on mouse move, you want the browser's compositor — not a JavaScript render loop — doing the heavy lifting. You'll get buttery 60 fps at basically zero CPU cost.

The mental model is this: your browser maintains a flat painting surface by default. Add perspective to a parent element and you switch the coordinate system so child elements can exist in three-dimensional space. From there, rotateX and rotateY on the card do exactly what they say. It's not magic — it's matrix multiplication the browser has been doing for years.

Honestly, most "3D card" libraries out there are just thin wrappers around exactly this. VanillaTilt.js, react-parallax-tilt — they're all computing the same mouse delta and applying the same CSS transform. Writing it yourself gives you far more control and cuts one more dependency from your bundle.

Worth noting: the technique pairs extremely well with glassmorphism cards. A translucent card that physically tilts toward the cursor feels genuinely tactile. If you haven't already explored glassmorphism components, that's a natural next step once you've got the 3D motion wired up.

How perspective and transform-style Actually Work

Two properties do almost everything. perspective goes on the parent — it defines the simulated distance between the viewer and the z=0 plane. A value of 800px gives a dramatic, fisheye-ish tilt. 1200px is subtler. Go below 400px and things start looking like a funhouse mirror. Most polished UIs land between 600px and 1000px.

.card-wrapper {
  perspective: 800px;
  perspective-origin: 50% 50%; /* default — viewer is centered */
}

.card {
  transform-style: preserve-3d; /* children also live in 3D space */
  transition: transform 0.1s ease-out;
  will-change: transform;
}

transform-style: preserve-3d is the one devs forget. Without it, any children of the card (a floating badge, a shine layer, an icon) get flattened back onto the card's 2D surface — you lose the layered depth. Set it on the card itself.

will-change: transform tells the browser to promote the element to its own compositor layer ahead of time. You're basically telling Chrome, "this is about to move, get it on the GPU now." Do this for animated elements, but don't spray it everywhere — it chews memory. One card or a small grid of cards: fine. A page of 200 cards: reconsider.

Quick aside: perspective-origin shifts where the vanishing point sits. Center is usually correct for a standalone card, but if your card lives in the corner of a dashboard you might want perspective-origin: 0% 0% so the tilt direction feels correct relative to the user's actual viewpoint.

Tracking the Mouse: the Math Behind the Tilt

The mouse position alone means nothing. You need the mouse position relative to the card's bounding box — specifically how far from center the cursor is, expressed as a fraction between -1 and +1. That fraction drives the rotation angle.

function getRotation(
  e: React.MouseEvent<HTMLDivElement>,
  el: HTMLDivElement,
  maxDeg = 15
): { rotX: number; rotY: number } {
  const rect = el.getBoundingClientRect();
  const cx = rect.left + rect.width / 2;
  const cy = rect.top + rect.height / 2;
  // normalize to [-1, 1]
  const dx = (e.clientX - cx) / (rect.width / 2);
  const dy = (e.clientY - cy) / (rect.height / 2);
  return {
    rotY: dx * maxDeg,  // mouse right → card tilts right
    rotX: -dy * maxDeg, // mouse down  → card tilts back
  };
}

The sign on rotX trips people up. Positive rotY rotates the card so the right edge comes toward you — intuitive. But positive rotX rotates the top edge toward you, which means mouse-down should give a negative rotX value. So you negate dy. Get this wrong and the card tilts away from the cursor instead of toward it, which feels deeply wrong.

maxDeg of 15° is a solid default. Below 8° and the effect is barely noticeable on large monitors. Above 20° and card content starts disappearing off the rotated edge on smaller viewports. In practice, 12–15° is where most product teams land after user testing.

One more thing — getBoundingClientRect() is called on every mousemove event. That's fine for a single card. If you've got a grid of 20 cards all tracking the mouse simultaneously, consider throttling with requestAnimationFrame or a useRef flag to cap at one calculation per frame.

Full React Component: 60 Lines, Zero Dependencies

Here's the complete component. It handles enter, move, and leave, adds a subtle specular shine layer that follows the light direction, and resets cleanly when the cursor exits.

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

interface Card3DProps {
  children: React.ReactNode;
  className?: string;
  maxDeg?: number;
  perspective?: number;
}

export function Card3D({
  children,
  className = '',
  maxDeg = 15,
  perspective = 800,
}: Card3DProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const [transform, setTransform] = useState('');
  const [shine, setShine] = useState({ x: 50, y: 50, opacity: 0 });

  const handleMove = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      const el = cardRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const cx = rect.left + rect.width / 2;
      const cy = rect.top + rect.height / 2;
      const dx = (e.clientX - cx) / (rect.width / 2);
      const dy = (e.clientY - cy) / (rect.height / 2);
      const rotY = dx * maxDeg;
      const rotX = -dy * maxDeg;
      setTransform(`rotateX(${rotX}deg) rotateY(${rotY}deg) scale3d(1.04,1.04,1.04)`);
      // shine follows cursor: top-left = dark, bottom-right = bright
      setShine({
        x: ((e.clientX - rect.left) / rect.width) * 100,
        y: ((e.clientY - rect.top) / rect.height) * 100,
        opacity: 0.18,
      });
    },
    [maxDeg]
  );

  const handleLeave = useCallback(() => {
    setTransform('');
    setShine((s) => ({ ...s, opacity: 0 }));
  }, []);

  return (
    <div style={{ perspective }} className="relative">
      <div
        ref={cardRef}
        onMouseMove={handleMove}
        onMouseLeave={handleLeave}
        style={{
          transform,
          transformStyle: 'preserve-3d',
          transition: transform ? 'transform 0.05s linear' : 'transform 0.4s ease-out',
          willChange: 'transform',
        }}
        className={`relative overflow-hidden rounded-2xl ${className}`}
      >
        {children}
        {/* specular shine overlay */}
        <div
          style={{
            position: 'absolute', inset: 0, pointerEvents: 'none',
            background: `radial-gradient(circle at ${shine.x}% ${shine.y}%, rgba(255,255,255,${shine.opacity}), transparent 70%)`,
            transition: 'opacity 0.3s',
            borderRadius: 'inherit',
          }}
        />
      </div>
    </div>
  );
}

The transition trick on line 33 deserves a closer look. When the mouse is moving over the card you want 0.05s linear — snappy, follows the cursor without lag. When the cursor leaves you want 0.4s ease-out so the card springs back smoothly instead of snapping. Switching the transition value based on whether transform is set handles both cases with one conditional string.

The shine overlay is a radial-gradient centered at the cursor position, expressed as percentages of the card's dimensions. As the cursor moves, the bright spot tracks it. This simulates specular light reflection — the same trick used on CSS glassmorphism cards and a lot of neobrutalism style components where a hard light source is implied.

Look, you could add a translateZ(20px) to a floating child element inside the card to push it visually out of the screen. Works great for price tags or badge elements. Just make sure the parent card has transform-style: preserve-3d or the child snaps back to the card surface.

Layered Depth: Making Elements Float Above the Card

Once transform-style: preserve-3d is set on the card, you can push child elements forward on the Z axis. This gives the illusion that certain content floats above the card surface — like a product image hovering 20px off a dark background panel.

// Inside Card3D, add a floating badge or image
<div
  style={{
    transform: 'translateZ(40px)',
    willChange: 'transform',
  }}
  className="absolute top-4 right-4 rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white"
>
  PRO
</div>

The perceived depth scales with the card's rotation. At 0° the badge looks flat. As you tilt the card, the badge's Z offset becomes visible parallax — it separates from the card surface and the depth reads clearly. This is the whole reason preserve-3d exists.

Be careful about font rendering at high translateZ values. Browsers sometimes sub-pixel-render text differently when it's promoted to its own composite layer in 3D space. If you see blurry text on a floating element, add backface-visibility: hidden and -webkit-font-smoothing: antialiased to the child. That usually clears it up.

In practice, two Z levels are enough for most cards: the card surface at translateZ(0) and one floating accent at translateZ(20px) to translateZ(40px). Going higher starts looking unnatural unless you're building an explicitly surreal cyberpunk or vaporwave-style layout where exaggerated depth is intentional.

Performance, Accessibility and Mobile Considerations

The mousemove event fires constantly — as in, multiple times per pixel of movement. For a single card that's trivial. For a product grid with 12 cards each registering their own listener, you're stacking handlers. Consolidate to one mousemove on the grid container, figure out which card the cursor is over using e.target.closest('[data-card]'), and apply the transform only to that card.

// Prefer media query for reduced-motion
const prefersReducedMotion =
  typeof window !== 'undefined' &&
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Then in handleMove:
if (prefersReducedMotion) return;

Accessibility matters here. Some users have vestibular disorders that make screen motion genuinely painful. Always respect prefers-reduced-motion. The check above is the simplest implementation — bail out of the mouse handler entirely and let the card sit flat. You could also use the CSS media query to set transition: none and transform: none !important on the card if you prefer keeping the JS logic clean.

Touch devices don't have mousemove. You could wire up ontouchmove and convert e.touches[0].clientX/Y to the same rotation math — but honestly, tilt effects on mobile feel gimmicky more often than they feel polished. The parallax depth you get from translateZ layers is invisible on a flat touch screen anyway. Most production implementations skip the tilt on touch and just render the card flat. Use a 'ontouchstart' in window check or a CSS hover media query to gate the feature.

One final thing on bundle size: this whole effect is zero bytes of third-party JavaScript. You're not shipping VanillaTilt (11 kB gzipped) or react-parallax-tilt (8 kB gzipped). If you need richer card components with pre-built variants — dark mode, glassmorphism, neobrutalism — browse components at Empire UI rather than assembling them from scratch. That said, now that you understand the underlying math, you'll be able to customize any pre-built card without guessing.

Quick Reference: The Numbers That Matter

Pulling the key values together so you don't have to skim back through the article. These are starting points — adjust based on your card size and visual context.

| Setting | Conservative | Default | Dramatic | |---|---|---|---| | perspective | 1200px | 800px | 500px | | maxDeg | | 15° | 22° | | translateZ float | 10px | 30px | 60px | | Reset transition | 0.5s ease-out | 0.4s ease-out | 0.25s ease-out | | Track transition | 0.08s linear | 0.05s linear | 0s (instant) |

The "dramatic" column is legitimately useful if you're building something like a card game UI, a portfolio hero, or a style-forward landing page where the 3D effect is the point. For a SaaS dashboard or a product listing, stay conservative — the effect should feel like quality, not like a demo.

And if you want to see how 3D depth layering interacts with translucent surfaces, the glassmorphism generator is a good sandbox. Add a card with backdrop-filter: blur(16px) and then layer the 3D tilt on top — the frosted glass effect plays surprisingly well with rotational transforms because the blur recomputes as the card angle changes.

FAQ

Does the 3D card effect work in all browsers?

Yes. perspective, rotateX/Y, and transform-style: preserve-3d have been supported across all major browsers since 2015. The only exception is backdrop-filter on the shine overlay, which you can safely drop as a progressive enhancement.

Why is my floating child element flat instead of 3D?

You're missing transform-style: preserve-3d on the parent card element. Without it, every child is flattened back onto the card's 2D surface regardless of their translateZ value.

Should I use react-parallax-tilt instead of writing this myself?

If you need advanced features like glare angle control, gyroscope support, or scale on hover, a library saves time. For a standard mouse-tracking tilt, the from-scratch version in this article is smaller and gives you full control.

How do I disable the effect on mobile?

Check 'ontouchstart' in window and skip the onMouseMove handler, or gate the entire feature behind a CSS @media (hover: hover) query. Tilt effects don't translate to touch and the gyroscope alternative has patchy browser support.

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

Read next

Spotlight Card in React: Cursor-Tracking Radial Highlight EffectNeumorphism Card in React: Soft UI with Correct Contrast RatiosCSS Parallax Without JavaScript: perspective, transform-style, LayersSpotlight Mouse Tracking Effect in React: Radial Gradient Cursor