Cards

Tilt Spotlight Card

A 3D-tilt card with a moving specular highlight and a subtle glare band that track the pointer.

Install

npx shadcn@latest add @paragon/tilt-spotlight-card

tilt-spotlight-card.tsx

"use client";

import * as React from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * TiltSpotlightCard — a 3D-tilt card that adds a moving specular highlight and
 * a subtle diagonal glare band, both tracking the pointer. The tilt reacts on
 * a spring, the highlight is a radial bloom at the pointer, and the glare is a
 * thin sheen that sweeps as the card rotates.
 *
 * Gated to `(hover: hover) and (pointer: fine)`; on touch it renders flat. The
 * card keeps `preserve-3d` and lifts on hover. Under reduced motion the tilt is
 * suppressed (highlight/glare are non-motion decoration). Wrap any content.
 */

export interface TiltSpotlightCardProps
  extends Omit<
    React.ComponentProps<"div">,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
  > {
  /** Max tilt in degrees on each axis. */
  maxTilt?: number;
  /** Specular highlight intensity, 0–1. */
  glare?: number;
  /** Highlight color. */
  color?: string;
  /** Hover lift in px. */
  lift?: number;
  children: React.ReactNode;
}

export function TiltSpotlightCard({
  maxTilt = 12,
  glare = 0.6,
  color = "#ffffff",
  lift = 6,
  className,
  children,
  ...props
}: TiltSpotlightCardProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const [fine, setFine] = React.useState(false);

  const px = useMotionValue(0.5); // 0..1
  const py = useMotionValue(0.5);
  const active = useMotionValue(0);

  const spring = { stiffness: 260, damping: 26, mass: 0.6 };
  const rx = useSpring(
    useTransform(py, [0, 1], [maxTilt, -maxTilt]),
    spring,
  );
  const ry = useSpring(
    useTransform(px, [0, 1], [-maxTilt, maxTilt]),
    spring,
  );
  const z = useSpring(useTransform(active, [0, 1], [0, lift]), spring);

  // highlight position in %
  const hx = useTransform(px, (v) => `${v * 100}%`);
  const hy = useTransform(py, (v) => `${v * 100}%`);
  // glare angle follows horizontal position
  const glareBg = useTransform([px, py], ([vx, vy]) => {
    const angle = 120 + ((vx as number) - 0.5) * 60;
    const a = glare * 0.5 * ((vy as number) * 0.6 + 0.4);
    return `linear-gradient(${angle}deg, transparent 30%, color-mix(in oklch, ${color} ${Math.round(
      a * 100,
    )}%, transparent) 50%, transparent 70%)`;
  });
  const hlOpacity = useTransform(active, [0, 1], [0, glare]);
  const glareOpacity = useTransform(active, [0, 1], [0, 1]);

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches && !reduce);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, [reduce]);

  const onMove = (e: React.PointerEvent) => {
    if (!fine) return;
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    px.set((e.clientX - rect.left) / rect.width);
    py.set((e.clientY - rect.top) / rect.height);
  };
  const onEnter = () => fine && active.set(1);
  const onLeave = () => {
    active.set(0);
    px.set(0.5);
    py.set(0.5);
  };

  return (
    <div style={{ perspective: 1000 }} className="inline-block">
      <motion.div
        ref={ref}
        data-slot="tilt-spotlight-card"
        onPointerMove={onMove}
        onPointerEnter={onEnter}
        onPointerLeave={onLeave}
        style={
          fine
            ? {
                rotateX: rx,
                rotateY: ry,
                z,
                transformStyle: "preserve-3d",
              }
            : undefined
        }
        className={cn(
          "relative overflow-hidden rounded-2xl bg-card text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div style={{ transform: "translateZ(0)" }}>{children}</div>

        {fine && (
          <>
            {/* specular highlight */}
            <motion.div
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light"
              style={{
                opacity: hlOpacity,
                background: useTransform(
                  [hx, hy],
                  ([x, y]) =>
                    `radial-gradient(40% 40% at ${x} ${y}, ${color}, transparent 70%)`,
                ),
              }}
            />
            {/* glare sweep */}
            <motion.div
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay"
              style={{ opacity: glareOpacity, background: glareBg }}
            />
          </>
        )}
      </motion.div>
    </div>
  );
}