# 3D Tilt Card With Glare in React and Tailwind

> Put perspective on a wrapper, then on pointer move write rotateX and rotateY angles to CSS variables with style.setProperty inside requestAnimationFrame, never React state. A Tailwind class reads them, a radial gradient at the pointer adds the glare, and pointer leave resets the angles so a CSS transition eases the card flat. Skip touch input and keep the card flat under reduced motion.

- URL: https://qodesign.dev/blog/3d-tilt-card-hover-glare-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

Hover over a good product card and it leans toward your hand a little, with a soft light sliding across the surface. Ask Cursor, v0 or Lovable for that and you usually get one of two things: a tilt package added to your bundle for a single effect, or a component that stores the mouse position in React state and re-renders on every pixel of movement. Both work in the demo. Neither is what you want on a page full of cards.

The whole effect fits in about 60 lines of React and a handful of Tailwind classes, with no library. This post builds a tilt card that follows the mouse, adds a glare that tracks the pointer, springs back when you leave, does nothing on touch screens and stays flat for people who turned motion off.

I make the [Credit Card Checkout](/components/credit-card-checkout-v1) on qodesign, where a 3D card tilts and catches the light as you type. That piece uses Framer Motion. The code in this post is a smaller version written for this post, uses no animation library, and is not the component's source.

## How the tilt works

Two CSS pieces do the 3D part. The [perspective property](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/perspective) sets the distance between the z=0 plane and the viewer, which is what makes a rotated element look closer on one side and further on the other. It goes on the parent. Then the card itself gets `rotateX()` and `rotateY()`, and the angles come from where the pointer is.

The trick that keeps it fast: React never hears about the angles. The pointer handler writes four CSS custom properties straight onto the element with [style.setProperty](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration/setProperty), and a Tailwind class reads them. No state, no re-render, the browser just recomputes one transform.

## The component

```tsx
// components/tilt-card.tsx
"use client";

import { useRef, type PointerEvent, type ReactNode } from "react";

const MAX_TILT = 10; // degrees at the very edge

export function TiltCard({
  children,
  className = "",
}: {
  children: ReactNode;
  className?: string;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const frame = useRef(0);

  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    if (e.pointerType !== "mouse") return; // no tilt on touch or pen
    const el = ref.current;
    if (!el) return;
    const { clientX, clientY } = e;
    cancelAnimationFrame(frame.current);
    frame.current = requestAnimationFrame(() => {
      const r = el.getBoundingClientRect();
      const x = (clientX - r.left) / r.width; // 0 at the left edge, 1 at the right
      const y = (clientY - r.top) / r.height; // 0 at the top, 1 at the bottom
      el.style.setProperty("--rx", `${(0.5 - y) * 2 * MAX_TILT}deg`);
      el.style.setProperty("--ry", `${(x - 0.5) * 2 * MAX_TILT}deg`);
      el.style.setProperty("--gx", `${x * 100}%`);
      el.style.setProperty("--gy", `${y * 100}%`);
      el.dataset.active = "true";
    });
  }

  function onPointerLeave() {
    cancelAnimationFrame(frame.current);
    const el = ref.current;
    if (!el) return;
    el.style.setProperty("--rx", "0deg");
    el.style.setProperty("--ry", "0deg");
    delete el.dataset.active;
  }

  return (
    <div className="[perspective:800px]">
      <div
        ref={ref}
        onPointerMove={onPointerMove}
        onPointerLeave={onPointerLeave}
        className={`group relative overflow-hidden rounded-2xl transition-transform duration-500 ease-out [transform:rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] data-[active=true]:duration-100 motion-reduce:[transform:none] ${className}`}
      >
        {children}
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-data-[active=true]:opacity-100 motion-reduce:hidden"
          style={{
            background:
              "radial-gradient(circle at var(--gx, 50%) var(--gy, 50%), rgb(255 255 255 / 0.3), transparent 60%)",
          }}
        />
      </div>
    </div>
  );
}
```

The math maps the pointer to a number from 0 to 1 on each axis using [getBoundingClientRect](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect), which gives the element's size and its position relative to the viewport, the same space `clientX` and `clientY` live in. The center of the card is 0.5, so `(x - 0.5) * 2` runs from -1 to 1, and multiplying by `MAX_TILT` gives at most 10 degrees at the edges. With these signs the side under the cursor sinks away from you, like pressing on it. Flip both signs if you want it to lift toward the cursor instead.

The same pointer numbers, as percentages, place the center of the glare. It's a white radial gradient fading to transparent, laid over the content with `pointer-events-none` so clicks still reach the link underneath.

### Why requestAnimationFrame

A mouse can send pointer events more often than the screen repaints. [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) asks the browser to call your function before the next repaint, and cancelling the previous frame first means at most one update per frame, always with the latest position. It also keeps the layout read from `getBoundingClientRect` out of the event handler itself.

One small honest note: the rect is measured on the tilted element, so its box shifts by a few pixels as it rotates. At 10 degrees you won't notice. If you push `MAX_TILT` much higher, put the ref for measuring on the outer wrapper, which never rotates.

### Two durations

While the pointer moves, `data-active` is set and the transition is 100ms, short enough to feel attached to your hand but long enough to smooth out jitter. When the pointer leaves, the attribute goes away, the angles go back to 0 and the 500ms transition eases the card flat again. That's the spring back, without a spring library. Use `transition-transform`, never `transition-all`, so nothing else on the card starts animating with it.

### Touch and reduced motion

The handler returns early unless [pointerType](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType) is `"mouse"`. On a phone a finger drag is a scroll, and a card that twists under your thumb while you scroll feels broken. If you'd rather decide in CSS, the [hover media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/hover) tests whether the primary input can hover.

For people who set their device to [reduce motion](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion), `motion-reduce:[transform:none]` keeps the card flat and `motion-reduce:hidden` removes the glare. Tailwind puts these inside a media query after the base classes, so they win without `!important`. The [reduced motion post](/blog/prefers-reduced-motion-react) covers the same setting from the React side.

## Using it

The card is a client component, but whatever you put inside can stay a server component. Here it wraps a link to a template:

```tsx
// components/template-card.tsx
import Link from "next/link";
import { TiltCard } from "./tilt-card";

export function TemplateCard({
  href,
  title,
  price,
  image,
}: {
  href: string;
  title: string;
  price: string;
  image: string;
}) {
  return (
    <TiltCard className="bg-zinc-900 text-white shadow-xl">
      <Link
        href={href}
        className="block rounded-2xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-white"
      >
        <img src={image} alt="" className="aspect-[16/10] w-full rounded-xl object-cover" loading="lazy" />
        <div className="mt-3 flex items-baseline justify-between">
          <span className="font-medium">{title}</span>
          <span className="text-sm text-zinc-400">{price}</span>
        </div>
      </Link>
    </TiltCard>
  );
}
```

Keyboard users get the card flat, which is fine: the tilt is decoration, and the focus ring is what tells them where they are. The plain `img` keeps the example short. Swap in `next/image` in a real app.

If the card holds layers that should float at different depths, add `[transform-style:preserve-3d]` to the tilted element. [transform-style](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transform-style) decides whether children are placed in 3D space or flattened into the card. Note that `overflow-hidden`, which clips the glare to the rounded corners here, flattens children too, so a layered card needs the glare clipped another way. For a card that flips over completely, see the [3D flip card post](/blog/3d-flip-card-react-tailwind-no-library).

## Mistakes AI tools make with tilt cards

- Mouse position in useState. Every pointer move re-renders the card and everything in it. Write CSS variables to the element instead.
- Perspective on the wrong element. The `perspective` property affects the element's children, so putting it on the same div you rotate does nothing. Put it on a wrapper, or use `perspective(800px)` as the first function inside the transform.
- `transition-all` with a long duration. The card lags a quarter second behind the mouse and feels like it's on a rubber band.
- No reset on pointer leave. The card stays frozen at whatever angle it had when the mouse left.
- A glare overlay that eats clicks. Without `pointer-events-none` the link under it stops working.
- Tilt on touch. Cards twist while people scroll.
- Big angles. 25 or 30 degrees looks dramatic in a screenshot and makes text hard to read. Somewhere around 8 to 12 is plenty.

## A prompt for Cursor, v0 or Lovable

```text
Build a TiltCard client component in React + TypeScript + Tailwind with no animation library.
- Outer div with [perspective:800px]. Inner div is the card: relative, overflow-hidden, rounded-2xl.
- On pointermove (only when pointerType is "mouse"), inside requestAnimationFrame (cancel the previous frame), read getBoundingClientRect and set CSS variables on the card with style.setProperty: --rx = (0.5 - y) * 2 * 10deg, --ry = (x - 0.5) * 2 * 10deg, --gx and --gy = pointer position in %.
- Never store the pointer position in React state.
- Card class: [transform:rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))] transition-transform duration-500 ease-out, and data-[active=true]:duration-100 while the pointer moves.
- On pointerleave: cancel the frame, set --rx and --ry to 0deg, remove data-active.
- Glare: absolute inset-0, pointer-events-none, aria-hidden, radial-gradient at var(--gx) var(--gy) from white at 30% opacity to transparent at 60%, opacity-0 that becomes opacity-100 with group-data-[active=true].
- Reduced motion: motion-reduce:[transform:none] on the card and motion-reduce:hidden on the glare.
```

## A tilt card that does more

The component above is one effect on purpose. The [Credit Card Checkout](/components/credit-card-checkout-v1) is the full version: a live 3D card that tilts with a glare, fills in as you type, detects the brand, checks the number with Luhn and flips over for the CVC, then runs a Pay spinner and a success state. The [credit card input post](/blog/credit-card-input-format-brand-luhn-react) walks through the formatting and validation side of it.

## FAQ

### How do I make a 3D tilt card in React without a library?

Put `perspective` on a wrapper, then on pointer move compute the pointer position inside the card and write rotateX and rotateY angles to CSS variables with `style.setProperty`. A Tailwind class like `[transform:rotateX(var(--rx,0deg))_rotateY(var(--ry,0deg))]` applies them, and resetting the variables on pointer leave lets a CSS transition ease the card back.

### Why is my tilt card laggy or janky?

The usual causes are storing the mouse position in React state, which re-renders on every move, and `transition-all` with a long duration, which makes the card trail the pointer. Write CSS variables directly, update them once per frame with requestAnimationFrame, and use a short transition while the pointer is moving.

### Why doesn't perspective do anything on my card?

The `perspective` property applies to an element's children. If you set it on the same element you rotate, there's no visible depth. Move it to a parent, or start the transform with the `perspective()` function.

### Should a tilt card work on mobile?

Usually not. On touch screens a drag is a scroll, so tilting under a finger fights the page. Check `pointerType` in the handler, or use the hover media query, and respect prefers-reduced-motion by keeping the card flat.
