Tilt Spotlight Card
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
 * zero-bounce springs, 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, under
 * `prefers-reduced-motion`, or with `static` it renders as a flat card. All
 * motion values are created unconditionally at the top level (hooks never
 * hide behind the gate) — the gate only decides whether they're wired to the
 * element. 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;
  /** Renders a plain, non-tilting card. */
  static?: boolean;
  children: React.ReactNode;
}

export function TiltSpotlightCard({
  maxTilt = 12,
  glare = 0.6,
  color = "#ffffff",
  lift = 6,
  static: isStatic = false,
  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);

  // Specular bloom at the pointer.
  const highlightBg = useTransform([px, py], ([vx, vy]) => {
    return `radial-gradient(40% 40% at ${(vx as number) * 100}% ${(vy as number) * 100}%, ${color}, transparent 70%)`;
  });
  // Glare: a thin, feathered sheen band whose angle tracks the pointer. Kept
  // narrow and low-opacity so it reads as light grazing the surface, never a
  // colored wash.
  const glareBg = useTransform([px, py], ([vx, vy]) => {
    const angle = 120 + ((vx as number) - 0.5) * 60;
    const a = glare * 0.32 * ((vy as number) * 0.5 + 0.5);
    const peak = color;
    return `linear-gradient(${angle}deg, transparent 42%, color-mix(in oklch, ${peak} ${Math.round(
      a * 100,
    )}%, transparent) 50%, transparent 58%)`;
  });
  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 enabled = fine && !isStatic;

  const onMove = (e: React.PointerEvent) => {
    if (!enabled) 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 = () => enabled && 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={
          enabled
            ? {
                rotateX: rx,
                rotateY: ry,
                z,
                transformStyle: "preserve-3d",
              }
            : undefined
        }
        className={cn(
          "relative overflow-hidden rounded-xl bg-card text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover",
          className,
        )}
        {...props}
      >
        <div style={{ transform: "translateZ(0)" }}>{children}</div>

        {enabled && (
          <>
            {/* specular highlight */}
            <motion.div
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light"
              style={{ opacity: hlOpacity, background: highlightBg }}
            />
            {/* glare sweep — soft-light keeps the sheen neutral, never a wash */}
            <motion.div
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-soft-light"
              style={{ opacity: glareOpacity, background: glareBg }}
            />
          </>
        )}
      </motion.div>
    </div>
  );
}