parallax /
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-cardWhere 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.