TL;DR 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.
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 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.
Two CSS pieces do the 3D part. The perspective property 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, and a Tailwind class reads them. No state, no re-render, the browser just recomputes one transform.
Credit Card Checkout V1 running live. A live 3D card that fills in as you type and flips for the CVC.
// components/tilt-card.tsx"use client";import { useRef, type PointerEvent, type ReactNode } from "react";const MAX_TILT = 10; // degrees at the very edgeexport 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, 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.
A mouse can send pointer events more often than the screen repaints. 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.
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.
The handler returns early unless 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 tests whether the primary input can hover.
For people who set their device to reduce 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 covers the same setting from the React side.
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 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.
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.
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.
The component above is one effect on purpose. The Credit Card Checkout 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 walks through the formatting and validation side of it.
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.
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.
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.
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.