Pointer tilt
A card that tilts toward the pointer in perspective, with a soft top light that follows.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/pointer-tilt.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/pointer-tilt
One study card. Move across it: six degrees at the edge, and the light tracks you.
Three cards
Text cards at a calmer four degrees, for a row of features.
Usage
import { PointerTilt } from "@/components/ui/pointer-tilt"
<PointerTilt className="max-w-md">
<img src="/cover.jpg" alt="The cover" />
<p className="p-5">Study 04</p>
</PointerTilt>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| max | number | 6 | Peak tilt in degrees at the card's edge, capped at 6. |
Source
pointer-tilt/pointer-tilt.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP);
const LIVE = "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";
const MAX_ANGLE = 6;
type PointerTiltProps = React.ComponentProps<"div"> & {
/** Peak tilt in degrees at the card's edge. Capped at 6, past which text starts to swim. */
max?: number;
};
/**
* A card that tilts toward the pointer in perspective, with a soft top light that follows. The outer
* box stays flat so the pointer maths cannot chase the tilt. Flat on touch and under reduced motion.
*/
function PointerTilt({ max = MAX_ANGLE, className, children, ...props }: PointerTiltProps) {
const root = React.useRef<HTMLDivElement>(null);
const card = React.useRef<HTMLDivElement>(null);
const light = React.useRef<HTMLDivElement>(null);
const angle = Math.min(max, MAX_ANGLE);
useGSAP(
() => {
const box = root.current;
if (!box) return;
const media = gsap.matchMedia();
media.add(LIVE, () => {
gsap.set(card.current, { transformPerspective: 900 });
const follow = { duration: 0.5, ease: "power3.out" };
const toRotateX = gsap.quickTo(card.current, "rotationX", follow);
const toRotateY = gsap.quickTo(card.current, "rotationY", follow);
const toLightX = gsap.quickTo(light.current, "x", follow);
const toLightY = gsap.quickTo(light.current, "y", follow);
const toLight = gsap.quickTo(light.current, "opacity", { duration: 0.6, ease: "power2.out" });
const move = (event: PointerEvent) => {
const rect = box.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
toRotateX(-y * 2 * angle);
toRotateY(x * 2 * angle);
toLightX(x * rect.width);
// The light stays near the top edge: it slides across and only dips a little.
toLightY((y + 0.5) * rect.height * 0.25);
toLight(1);
};
const leave = () => {
toRotateX(0);
toRotateY(0);
toLight(0);
};
box.addEventListener("pointermove", move, { passive: true });
box.addEventListener("pointerleave", leave);
return () => {
box.removeEventListener("pointermove", move);
box.removeEventListener("pointerleave", leave);
};
});
return () => media.revert();
},
{ scope: root, dependencies: [angle], revertOnUpdate: true },
);
return (
<div ref={root} data-slot="pointer-tilt" className={cn("relative", className)} {...props}>
<div ref={card} className="surface-shine relative h-full overflow-hidden rounded-lg border bg-card">
{children}
<div
ref={light}
aria-hidden="true"
className="pointer-events-none absolute -top-1/2 left-0 aspect-square w-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--background)_55%,transparent),transparent)] opacity-0 dark:bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--foreground)_30%,transparent),transparent)]"
/>
</div>
</div>
);
}
export { PointerTilt };
For AI agents
Hand your agent this page, the markdown at /components/pointer-tilt/prompt.md, or the Copy AI prompt button above.
When to use
- One or a few feature cards on a marketing page
- A cover or a product shot that deserves a moment of attention
- Beside a flat layout, where small depth reads as craft
When not to use
- Dense grids, where tilting tiles compete with each other
- Cards that hold long text or forms: reading on a moving plane is tiring
- App surfaces people use all day
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- It renders the card surface itself, so pass content, not another card. className goes on the flat outer box
- Tilt runs only on a fine pointer with motion allowed. Keyboard focus and clicks inside work as normal
- Leave room around it: a 6 degree tilt at 900px perspective scales the near edge of a 576px card by 3.5 percent
The maths
- Rotate Y is 2 × max × (x - 0.5) and rotate X is -2 × max × (y - 0.5), so the centre is flat and each edge reaches max.
- At 900px perspective a 576px card at 6 degrees brings its near edge 288 × sin 6° = 30.1px closer, which scales it by 900 / 869.9, or 3.5 percent.