Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
maxnumber6Peak 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.