EmpireUI
Get Pro
← Blog7 min read#parallax#tilt-effect#react

Parallax Tilt Card: 3D Perspective on Mouse Move in React

Build a smooth 3D parallax tilt card in React with mouse tracking, CSS perspective, and no heavy libraries — just clean TypeScript and Tailwind v4.0.2.

Abstract 3D geometric shapes with dramatic lighting showing depth and perspective

Why Parallax Tilt Cards Still Work in 2026

Honestly, parallax tilt cards are one of those micro-interactions that never get old — because they're rooted in how humans perceive depth, not in a passing design fad. When a card tilts to follow your cursor and inner layers shift at different speeds, your brain reads it as a physical object. That's not a trick, it's just perspective.

You'll see this pattern on portfolio sites, product showcases, SaaS pricing pages, and landing pages for dev tools. Done badly, it's nauseating. Done well — with a small rotation range (8–12 degrees max), smooth spring easing, and a subtle glare overlay — it makes your UI feel expensive without adding a single millisecond of network time.

The entire effect runs on CSS transform: perspective() rotateX() rotateY(). No WebGL, no canvas, no third-party animation engine. Just a mousemove handler and some math. If you've already built something like a spotlight effect in React, you'll find the event-handling pattern here almost identical.

The Math Behind 3D Tilt on Mouse Move

The core idea is mapping the mouse position within the card's bounding box to a rotation angle. You get the card's getBoundingClientRect(), then calculate where the cursor sits as a percentage of the card's width and height. From there, you map that percentage to a rotation range — say, -10 to 10 degrees on each axis.

For rotateY (left-right tilt), the formula is: rotateY = ((mouseX / cardWidth) - 0.5) * maxAngle * 2. Same idea for rotateX but inverted on the Y axis — when the cursor is at the top, the card tilts forward (positive rotateX). When it's at the bottom, it tilts back. That inversion is what makes it feel natural.

The perspective value on the parent container controls how dramatic the 3D effect looks. A small value like 300px creates an exaggerated fisheye feel. Something around 800px to 1200px is readable and polished. We'll use perspective: 1000px in the implementation below — it hits the sweet spot for most card sizes.

Building the Parallax Tilt Card Component

Here's a self-contained TiltCard component in TypeScript. It uses a useRef for the card element, a useCallback for the event handler, and inline styles for the transform values. No external dependencies — works with any React 18+ project and Tailwind v4.0.2.

// TiltCard.tsx
import { useRef, useCallback, useState, ReactNode } from 'react';

interface TiltCardProps {
  children: ReactNode;
  maxAngle?: number;      // degrees, default 10
  perspective?: number;   // px, default 1000
  scale?: number;         // hover scale, default 1.04
  className?: string;
}

export function TiltCard({
  children,
  maxAngle = 10,
  perspective = 1000,
  scale = 1.04,
  className = '',
}: TiltCardProps) {
  const cardRef = useRef<HTMLDivElement>(null);
  const [transform, setTransform] = useState(
    'rotateX(0deg) rotateY(0deg) scale(1)'
  );
  const [glare, setGlare] = useState({ x: 50, y: 50, opacity: 0 });

  const handleMouseMove = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      const card = cardRef.current;
      if (!card) return;
      const rect = card.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const rotateY = ((x / rect.width) - 0.5) * maxAngle * 2;
      const rotateX = -((y / rect.height) - 0.5) * maxAngle * 2;
      setTransform(
        `perspective(${perspective}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(${scale})`
      );
      // glare position as % of card size
      setGlare({
        x: (x / rect.width) * 100,
        y: (y / rect.height) * 100,
        opacity: 0.18,
      });
    },
    [maxAngle, perspective, scale]
  );

  const handleMouseLeave = useCallback(() => {
    setTransform('rotateX(0deg) rotateY(0deg) scale(1)');
    setGlare((g) => ({ ...g, opacity: 0 }));
  }, []);

  return (
    <div style={{ perspective: `${perspective}px` }}>
      <div
        ref={cardRef}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        style={{
          transform,
          transition: 'transform 0.08s ease-out',
          willChange: 'transform',
          position: 'relative',
          overflow: 'hidden',
        }}
        className={`rounded-2xl ${className}`}
      >
        {/* glare overlay */}
        <div
          aria-hidden
          style={{
            position: 'absolute',
            inset: 0,
            pointerEvents: 'none',
            background: `radial-gradient(circle at ${glare.x}% ${glare.y}%, rgba(255,255,255,0.28) 0%, transparent 65%)`,
            opacity: glare.opacity,
            transition: 'opacity 0.15s ease',
            borderRadius: 'inherit',
          }}
        />
        {children}
      </div>
    </div>
  );
}

The glare overlay is a radial-gradient positioned at the cursor's percentage coordinates inside the card. It tracks the mouse but fades in gently (opacity transition at 0.15s) to avoid a jarring flash. On mouseleave both the tilt and the glare reset to their neutral state.

Adding Inner Parallax Layers

A single-surface tilt already looks great. But the real depth comes from moving child elements at *different* rates relative to the parent. The technique is straightforward: pass rotateX and rotateY values down via a context or props, then translate each layer by a fraction of those values using translateX and translateY.

Say your card has a background image, a badge, and a heading. The background barely moves (multiplier 0.3), the badge shifts a bit more (multiplier 0.8), and the heading floats the most (multiplier 1.4). Each layer gets a transform: translateX() translateY() driven by those multiplied tilt values. The result is a genuine sense of Z-depth without touching a single Z-index.

Keep the translation amounts small. For a 300px wide card with ±10deg tilt, a multiplier of 1.5 translates to roughly ±4px to ±5px of layer shift. That's enough to read as parallax. Go higher and the text starts jumping around in a way that feels broken. If you've played with Aurora Background in React before, you'll recognise this layered-motion thinking.

Smoothing the Animation with Spring Easing

The 0.08s ease-out transition on the transform works fine for snappy cursor tracking. But if you want a springy, physical feel — where the card overshoots slightly and settles — you have two options without pulling in a full animation library.

Option one: use CSS transition with a custom cubic-bezier. Something like cubic-bezier(0.03, 0.98, 0.52, 0.99) approximates a light spring. Option two: run a requestAnimationFrame loop with linear interpolation (lerp). Each frame, move the current tilt value 10–20% of the remaining distance to the target. This gives you a smooth trailing effect that CSS transitions can't replicate precisely because the target keeps changing as the mouse moves.

The lerp approach looks like this in practice: currentX += (targetX - currentX) * 0.12. Run that in a rAF loop and cancel it on mouseleave. For most use cases though, the CSS transition is perfectly adequate and far simpler. Don't optimise prematurely — is a physics spring actually going to improve conversion on your pricing card, or just eat an afternoon?

Performance and Accessibility Considerations

A few things will make or break your tilt card in production. First, will-change: transform on the card element tells the browser to promote it to its own compositor layer, which eliminates layout thrashing during the animation. Add it. Remove it from elements that aren't currently animating if you have many cards on the page — a hundred will-change declarations simultaneously is a memory concern.

Second, throttle or skip the mousemove handler if you're rendering many tilt cards at once. A requestAnimationFrame flag (let rafPending = false) inside the handler is all you need: set it to true at the start of a frame, run the update, set it back to false after the rAF callback fires. This limits updates to the display refresh rate.

Third — and this matters for users who set their OS-level 'reduce motion' preference — wrap the transform logic in a prefers-reduced-motion check. The easiest React approach is a custom hook: const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches. If true, skip the tilt entirely and just render the card flat. You can combine this with the same technique used in Particles Background React where motion is toggled based on that media query.

Styling the Card with Tailwind v4.0.2

Tailwind v4.0.2 ships CSS-first configuration, which means you define your design tokens in CSS rather than tailwind.config.js. For a tilt card that works across light and dark modes with glassmorphism styling, here's a practical starting point that pairs cleanly with the component above.

// Usage example — drop this in your page
import { TiltCard } from './TiltCard';

export function PricingCard() {
  return (
    <TiltCard
      maxAngle={8}
      perspective={900}
      scale={1.03}
      className={
        // Tailwind v4.0.2 utility classes
        'bg-white/10 backdrop-blur-md border border-white/20 ' +
        'p-8 shadow-xl shadow-black/20 text-white'
      }
    >
      <span className="inline-block mb-4 px-3 py-1 rounded-full text-xs font-semibold bg-white/20">
        PRO
      </span>
      <h3 className="text-2xl font-bold mb-2">$29 / month</h3>
      <p className="text-white/70 text-sm leading-relaxed mb-6">
        Everything in Free, plus unlimited exports and priority support.
      </p>
      <button className="w-full py-2.5 rounded-xl bg-white text-black font-semibold hover:bg-white/90 transition-colors">
        Get started
      </button>
    </TiltCard>
  );
}

Notice the bg-white/10 backdrop-blur-md border border-white/20 trio — this is the glassmorphism baseline that makes the 3D tilt visually coherent. The translucent fill means light catches the card edge differently as it rotates, amplifying the three-dimensional illusion. If you want to understand that combination more deeply, the glassmorphism guide covers it end-to-end.

Touch Devices, SSR, and Common Gotchas

Mouse tracking doesn't exist on touch devices. You have two sensible options: skip the tilt entirely on touch (simplest), or map touchmove events to the same rotation logic. Most designers prefer no tilt on mobile — the cards still look beautiful at rest, and the interaction doesn't translate well to a finger dragging on glass.

For SSR environments (Next.js App Router, Remix), window doesn't exist at render time. Guard any matchMedia or getBoundingClientRect calls behind a typeof window !== 'undefined' check, or run them inside a useEffect. The component above is safe because all DOM access happens in event handlers, which only fire client-side.

One gotcha: if your card is inside a CSS transform context (like a parent with transform: translateZ(0)) the getBoundingClientRect coordinates can be off. If your tilt feels misaligned, that's usually the culprit — remove the parent transform or account for it when calculating the cursor offset relative to the card.

FAQ

Do I need a library like react-parallax-tilt or vanilla-tilt to get this effect?

No. The math is a handful of lines — mouse position relative to the card, mapped to a rotation range. A library is reasonable if you need advanced features like device gyroscope support or easing presets out of the box, but for a standard mouse-driven tilt, the native implementation is lighter and gives you full control over the exact feel.

What's a good maxAngle value so the card doesn't look ridiculous?

8 to 12 degrees total rotation (±4 to ±6 deg per axis). Beyond 15 degrees, the perspective distortion starts warping text readability and the card reads as a bug rather than an effect. Start at 10 and dial it down if stakeholders find it too aggressive.

The tilt animation feels jerky on my 120Hz display. What's wrong?

You're probably setting state inside the mousemove handler on every event, which triggers a re-render on every pixel moved. Throttle it with a requestAnimationFrame flag, or switch from setState to updating the transform style directly via a ref: cardRef.current.style.transform = .... Direct DOM mutation in a rAF loop runs at display refresh rate and bypasses React's render cycle entirely.

How do I reset the tilt when the mouse leaves the card?

Attach an onMouseLeave handler that resets your transform state to 'rotateX(0deg) rotateY(0deg) scale(1)'. The CSS transition handles the smooth return animation — the card glides back to flat. Set the transition duration a bit longer on reset (around 0.3s to 0.4s) than on active tracking (0.08s) so the return feels deliberate rather than snappy.

Will this hurt my Core Web Vitals or Lighthouse score?

Not meaningfully. The tilt effect is pure CSS transform — it runs on the GPU compositor thread and doesn't trigger layout or paint. The main risk is forgetting will-change: transform, which causes the browser to repaint the card on every frame. Add that and you'll score cleanly on CLS and INP.

Can I combine the tilt card with a glassmorphism background?

Yes, and it looks great. The translucent background shifts subtly as the card rotates because the blurred content behind it changes angle, which naturally reinforces the 3D illusion. Use bg-white/10 backdrop-blur-md on the card itself and put a vivid gradient or animated background behind the whole scene.

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

Read next

CSS Flip Card Animation: 3D Reveal Without JavaScriptCSS Animations & Motion Design: The Complete 2026 PlaybookParallax Scroll Sections in React: Performance-First ApproachTailwind Animation Library: 30 Classes for Common Effects