Skip to content
ARDENT / MOTION LAB
Library

3D / Experimental · Intermediate

3D Card Tilt with Glare

Pointer position maps to rotateX/rotateY springs; a radial-gradient glare follows. Content lifts with translateZ in preserve-3d.

Live preview

Move a mouse over the card, or focus it and use arrow keys.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Card Tilt
 * A collectible editorial card with spring tilt and keyboard controls.
 * Requires: motion, Tailwind with shadcn semantic tokens.
 */
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion, useSpring } from "motion/react";

export default function CardTilt({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useReducedMotion();
  const reduced = reducedProp ?? systemReduced ?? false;
  const amount = Math.min(1.5, Math.max(0.5, intensity));
  const rx = useSpring(0, { stiffness: 160, damping: 22 });
  const ry = useSpring(0, { stiffness: 160, damping: 22 });
  const [selected, setSelected] = useState(false);
  const instructionsId = useId();
  const limit = 10 * amount;

  useEffect(() => {
    if (reduced) {
      rx.jump(0);
      ry.jump(0);
    }
  }, [reduced, rx, ry]);

  function reset() {
    rx.set(0);
    ry.set(0);
  }

  return (
    <section className="flex min-h-[80vh] flex-col items-center justify-center bg-background px-6 py-20 text-foreground">
      <p className="mb-10 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
        Objects of attention / 12
      </p>
      <div className="w-full max-w-[340px]" style={{ perspective: 1000 }}>
        <motion.button
          type="button"
          aria-pressed={selected}
          aria-describedby={instructionsId}
          onClick={() => setSelected((value) => !value)}
          onPointerLeave={reset}
          onPointerCancel={reset}
          onBlur={reset}
          onPointerMove={(event) => {
            if (
              reduced ||
              event.pointerType !== "mouse" ||
              !window.matchMedia("(hover: hover) and (pointer: fine)").matches
            )
              return;
            const rect = event.currentTarget.getBoundingClientRect();
            ry.set(((event.clientX - rect.left) / rect.width - 0.5) * limit * 2);
            rx.set((0.5 - (event.clientY - rect.top) / rect.height) * limit * 2);
          }}
          onKeyDown={(event) => {
            if (reduced) return;
            if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Escape"].includes(event.key))
              return;
            event.preventDefault();
            if (event.key === "Escape") reset();
            if (event.key === "ArrowLeft") ry.set(Math.max(-limit, ry.get() - 3 * amount));
            if (event.key === "ArrowRight") ry.set(Math.min(limit, ry.get() + 3 * amount));
            if (event.key === "ArrowUp") rx.set(Math.min(limit, rx.get() + 3 * amount));
            if (event.key === "ArrowDown") rx.set(Math.max(-limit, rx.get() - 3 * amount));
          }}
          style={{
            rotateX: reduced ? 0 : rx,
            rotateY: reduced ? 0 : ry,
            transformStyle: "preserve-3d",
          }}
          className="relative block aspect-[3/4] w-full overflow-hidden rounded-2xl border border-border bg-card text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-8"
        >
          <img
            src="https://picsum.photos/seed/ardent-monolith/900/1200"
            alt="Photographic artwork for the Monolith collector edition."
            className="absolute inset-0 h-full w-full object-cover"
          />
          <div className="absolute inset-x-0 bottom-0 border-t border-border bg-background p-6">
            <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
              Collector edition / 014
            </p>
            <h2 className="mt-3 font-serif text-5xl tracking-tight">Monolith</h2>
            <p aria-live="polite" className="mt-3 text-sm text-muted-foreground">
              {selected ? "Added to your collection" : "Select to collect"}
            </p>
          </div>
        </motion.button>
      </div>
      <p
        id={instructionsId}
        className="mt-8 max-w-sm text-center text-sm leading-relaxed text-muted-foreground"
      >
        {reduced
          ? "Press Enter or tap the card to add it to your collection."
          : "Move your pointer, or focus and use arrow keys. Escape resets the tilt."}
      </p>
    </section>
  );
}
Next patternScroll Reveal Mask→