Innovations

Mouse-tilt Card

Inline product/article cards that tilt in 3D following the cursor, with a radial

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

export interface TiltCard {
  src: string;
  eyebrow?: string;
  title: string;
  caption?: string;
  href?: string;
}

export interface ParallaxMouseTiltCardProps {
  sectionEyebrow?: string;
  sectionHeadline?: string;
  cards?: TiltCard[];
  /** Max tilt in degrees. */
  tilt?: number;
}

const DEFAULT_CARDS: TiltCard[] = [
  {
    src: "/heroes/product-cutout/model.webp",
    eyebrow: "S/S Collection",
    title: "Linen Mock Tee",
    caption: "Belgian-milled, garment-dyed in small batches",
    href: "#",
  },
  {
    src: "/heroes/cmillworks/walnut.webp",
    eyebrow: "Furniture",
    title: "Walnut Slab Desk",
    caption: "Single-board live edge, hand-finished oil",
    href: "#",
  },
  {
    src: "/heroes/blob-portrait/woman.webp",
    eyebrow: "Editorial",
    title: "On Slow Mornings",
    caption: "A field guide to keeping the first hour to yourself",
    href: "#",
  },
];

export default function ParallaxMouseTiltCard({
  sectionEyebrow = "Parallax · Mouse Tilt Card",
  sectionHeadline = "Hover any card",
  cards = DEFAULT_CARDS,
  tilt = 9,
}: ParallaxMouseTiltCardProps) {
  const refs = useRef<(HTMLDivElement | null)[]>([]);

  useEffect(() => {
    if (typeof window === "undefined") return;
    if (window.matchMedia("(hover: none)").matches) return; // touch — skip

    const cleanups: (() => void)[] = [];
    refs.current.forEach((el) => {
      if (!el) return;
      const inner = el.querySelector<HTMLElement>("[data-tilt-inner]");
      const shine = el.querySelector<HTMLElement>("[data-tilt-shine]");
      if (!inner) return;

      let raf = 0;
      const onMove = (e: MouseEvent) => {
        const r = el.getBoundingClientRect();
        const x = (e.clientX - r.left) / r.width - 0.5;
        const y = (e.clientY - r.top) / r.height - 0.5;
        cancelAnimationFrame(raf);
        raf = requestAnimationFrame(() => {
          inner.style.transform = `perspective(900px) rotateX(${-y * tilt}deg) rotateY(${x * tilt}deg) translateZ(0)`;
          if (shine) {
            shine.style.opacity = "1";
            shine.style.background = `radial-gradient(420px circle at ${(x + 0.5) * 100}% ${(y + 0.5) * 100}%, rgba(255,255,255,0.22), transparent 55%)`;
          }
        });
      };
      const onLeave = () => {
        cancelAnimationFrame(raf);
        inner.style.transform = `perspective(900px) rotateX(0) rotateY(0)`;
        if (shine) shine.style.opacity = "0";
      };

      el.addEventListener("mousemove", onMove);
      el.addEventListener("mouseleave", onLeave);
      cleanups.push(() => {
        el.removeEventListener("mousemove", onMove);
        el.removeEventListener("mouseleave", onLeave);
        cancelAnimationFrame(raf);
      });
    });

    return () => cleanups.forEach((fn) => fn());
  }, [tilt, cards]);

  return (
    <section className="relative w-full bg-stone-100 py-24 sm:py-32">
      <div className="mx-auto max-w-2xl px-6 text-center">
        <p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
          {sectionEyebrow}
        </p>
        <h2 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight text-stone-900 sm:text-5xl">
          {sectionHeadline}
        </h2>
      </div>

      <div className="mx-auto mt-16 grid max-w-6xl gap-6 px-6 sm:grid-cols-2 lg:grid-cols-3 lg:gap-8">
        {cards.map((c, i) => (
          <a
            key={c.src + i}
            href={c.href ?? "#"}
            ref={(el) => {
              refs.current[i] = el;
            }}
            className="group block [perspective:900px]"
          >
            <div
              data-tilt-inner
              className="relative overflow-hidden rounded-2xl bg-white shadow-[0_20px_50px_-20px_rgba(0,0,0,0.25)] ring-1 ring-stone-200/80 transition-transform duration-300 ease-out will-change-transform"
              style={{ transformStyle: "preserve-3d" }}
            >
              <div className="aspect-[4/5] w-full overflow-hidden bg-stone-200">
                <img
                  src={c.src}
                  alt=""
                  className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
                  loading="lazy"
                  decoding="async"
                />
              </div>
              <div className="p-5">
                {c.eyebrow && (
                  <p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-stone-500">
                    {c.eyebrow}
                  </p>
                )}
                <h3 className="mt-1.5 text-lg font-semibold text-stone-900">{c.title}</h3>
                {c.caption && (
                  <p className="mt-1 text-sm leading-relaxed text-stone-600">{c.caption}</p>
                )}
              </div>
              {/* Shine layer — radial gradient tracking the cursor */}
              <div
                data-tilt-shine
                aria-hidden
                className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300"
              />
            </div>
          </a>
        ))}
      </div>
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add mouse-tilt-card

Where to use it

Each card is its own perspective container; the inner panel rotates X/Y based on cursor position, and a radial-gradient overlay simulates 'shine' tracking the pointer. AUTO-DISABLED on touch (matchMedia '(hover: none)'). CONFIGURE: - cards: array of { src, eyebrow?, title, caption?, href? } — works at 2, 3, or 4 across (the grid is sm:grid-cols-2 lg:grid-cols-3) - tilt (default 9°) — max rotation in either axis. Above 12 starts to feel uncanny. WHEN TO USE: - Product grids (ecom, marketplaces, portfolios) - Featured-articles strip on a blog - 'Our work' / case-study showcases - Avoid for utility cards (settings, list items) — tilt makes them feel un-clickable PERFORMANCE: one rAF per card per mousemove; transforms are GPU-composited. Cost is paid only while a cursor is over a specific card. ACCESSIBILITY: the shine is decorative and won't affect screen readers. Cards remain semantic <a> tags. Tilt does not impair clickability.