Tilt Cards Gallery
Original · freeA grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/gallery-tilt-cards.json"use client";
import { useRef, useState } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
type Shot = {
src: string;
title: string;
category: string;
location: string;
span: "tall" | "wide" | "square";
};
const SHOTS: Shot[] = [
{
src: "/img/gallery/g03.webp",
title: "Coastal Light",
category: "Landscape",
location: "Cinque Terre, IT",
span: "tall",
},
{
src: "/img/gallery/g11.webp",
title: "Studio 04",
category: "Portrait",
location: "Berlin Atelier",
span: "square",
},
{
src: "/img/gallery/g18.webp",
title: "Low Tide",
category: "Nature",
location: "Skagen Shore",
span: "wide",
},
{
src: "/img/gallery/g07.webp",
title: "Marble & Fog",
category: "Still Life",
location: "Carrara Quarry",
span: "tall",
},
{
src: "/img/gallery/g22.webp",
title: "Terracotta Hour",
category: "Architecture",
location: "Marrakech Medina",
span: "square",
},
{
src: "/img/gallery/g14.webp",
title: "Northbound",
category: "Travel",
location: "Lofoten Islands",
span: "wide",
},
{
src: "/img/gallery/g29.webp",
title: "Quiet Bloom",
category: "Botanical",
location: "Kyoto Greenhouse",
span: "tall",
},
{
src: "/img/gallery/g05.webp",
title: "Concrete Poem",
category: "Urban",
location: "São Paulo",
span: "square",
},
{
src: "/img/gallery/g33.webp",
title: "Salt Flats",
category: "Landscape",
location: "Uyuni, BO",
span: "wide",
},
];
const SPAN_CLASS: Record<Shot["span"], string> = {
tall: "sm:row-span-2 aspect-[4/5]",
wide: "sm:col-span-2 aspect-[16/10]",
square: "aspect-square",
};
function TiltCard({ shot, index }: { shot: Shot; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
// Normalized pointer position within the card, range -0.5 .. 0.5
const px = useMotionValue(0);
const py = useMotionValue(0);
const springCfg = { stiffness: 220, damping: 22, mass: 0.6 };
const rotateX = useSpring(
useTransform(py, [-0.5, 0.5], [9, -9]),
springCfg,
);
const rotateY = useSpring(
useTransform(px, [-0.5, 0.5], [-11, 11]),
springCfg,
);
// Glare follows the pointer, composed into a live radial-gradient string
const glareX = useTransform(px, [-0.5, 0.5], ["0%", "100%"]);
const glareY = useTransform(py, [-0.5, 0.5], ["0%", "100%"]);
const glare = useTransform(
[glareX, glareY],
([gx, gy]: string[]) =>
`radial-gradient(closest-side circle at ${gx} ${gy}, rgba(255,255,255,0.55), rgba(255,255,255,0) 70%)`,
);
function handleMove(e: React.PointerEvent<HTMLDivElement>) {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
px.set((e.clientX - rect.left) / rect.width - 0.5);
py.set((e.clientY - rect.top) / rect.height - 0.5);
}
function reset() {
setHovered(false);
px.set(0);
py.set(0);
}
return (
<motion.div
className={`gtc-card group relative ${SPAN_CLASS[shot.span]}`}
style={{ perspective: 1000 }}
initial={{ opacity: 0, y: 26 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{
duration: 0.6,
delay: (index % 3) * 0.08 + Math.floor(index / 3) * 0.05,
ease: [0.22, 1, 0.36, 1],
}}
>
<motion.div
ref={ref}
role="group"
tabIndex={0}
aria-label={`${shot.title} — ${shot.category}, ${shot.location}`}
onPointerMove={handleMove}
onPointerEnter={() => setHovered(true)}
onPointerLeave={reset}
onBlur={reset}
style={{
rotateX,
rotateY,
transformStyle: "preserve-3d",
}}
className="relative h-full w-full cursor-pointer overflow-hidden rounded-2xl bg-neutral-200 outline-none ring-1 ring-inset ring-black/5 transition-shadow duration-500 [box-shadow:0_1px_2px_rgba(15,23,42,0.08),0_12px_30px_-16px_rgba(15,23,42,0.35)] hover:[box-shadow:0_2px_6px_rgba(15,23,42,0.12),0_40px_60px_-24px_rgba(15,23,42,0.55)] focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-800 dark:ring-white/10 dark:hover:[box-shadow:0_2px_6px_rgba(0,0,0,0.5),0_40px_70px_-24px_rgba(0,0,0,0.8)] dark:focus-visible:ring-indigo-400"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.src}
alt={`${shot.title}, a ${shot.category.toLowerCase()} photograph taken in ${shot.location}`}
loading="lazy"
draggable={false}
className="absolute inset-0 h-full w-full scale-[1.06] object-cover transition-transform duration-700 ease-out group-hover:scale-[1.12]"
style={{ transform: "translateZ(0)" }}
/>
{/* Depth vignette */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/75 via-neutral-950/5 to-transparent"
/>
{/* Soft glare that tracks the cursor */}
<motion.div
aria-hidden
className="pointer-events-none absolute -inset-8 opacity-0 mix-blend-soft-light transition-opacity duration-300 group-hover:opacity-100"
style={{ background: glare }}
/>
{/* Fine grain / sheen edge */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ring-white/15"
/>
{/* Caption — lifted forward in 3D space */}
<motion.div
style={{ transform: "translateZ(46px)" }}
className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col gap-1 p-4 sm:p-5"
>
<span className="w-fit rounded-full bg-white/15 px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.18em] text-white backdrop-blur-md ring-1 ring-inset ring-white/25">
{shot.category}
</span>
<div className="mt-1.5 flex items-end justify-between gap-3">
<h3 className="font-serif text-lg leading-tight text-white drop-shadow-sm sm:text-xl">
{shot.title}
</h3>
<span
className={`shrink-0 text-xs font-medium text-white/70 transition-all duration-500 ${
hovered
? "translate-y-0 opacity-100"
: "translate-y-1 opacity-0"
}`}
>
{shot.location}
</span>
</div>
</motion.div>
</motion.div>
</motion.div>
);
}
export default function GalleryTiltCards() {
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-neutral-50 via-white to-neutral-100 px-5 py-20 text-neutral-900 sm:px-8 sm:py-28 dark:from-neutral-950 dark:via-neutral-950 dark:to-black dark:text-neutral-50">
<style>{`
@keyframes gtc-float {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-3%, 4%) scale(1.08); }
}
.gtc-orb { animation: gtc-float 18s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.gtc-orb { animation: none; }
.gtc-card { transition: none !important; }
}
`}</style>
{/* Atmosphere */}
<div
aria-hidden
className="gtc-orb pointer-events-none absolute -left-24 top-10 h-80 w-80 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
/>
<div
aria-hidden
className="gtc-orb pointer-events-none absolute -right-20 bottom-0 h-96 w-96 rounded-full bg-emerald-200/30 blur-3xl [animation-delay:-9s] dark:bg-violet-700/15"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04] [background-image:radial-gradient(circle_at_1px_1px,currentColor_1px,transparent_0)] [background-size:22px_22px]"
/>
<div className="relative mx-auto max-w-6xl">
{/* Header */}
<div className="mb-12 flex flex-col items-start gap-5 sm:mb-16 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-neutral-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-neutral-500 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Field Notebook
</span>
<h2 className="mt-4 font-serif text-4xl leading-[1.05] tracking-tight sm:text-5xl">
Frames that lean
<span className="italic text-indigo-600 dark:text-indigo-400">
{" "}
toward you
</span>
</h2>
<p className="mt-4 max-w-md text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
A hand-picked set of nine. Move your cursor across a frame and it
tips into the light — glass catching a soft glare, the caption
rising forward.
</p>
</div>
<p className="text-sm font-medium text-neutral-400 dark:text-neutral-500">
09 / archive
</p>
</div>
{/* Grid */}
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3">
{SHOTS.map((shot, i) => (
<TiltCard key={shot.src} shot={shot} index={i} />
))}
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Hover Caption Gallery
OriginalA responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery
OriginalA Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery
OriginalA filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Lightbox Gallery
OriginalA thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery
OriginalA horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery
OriginalTwo rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery
OriginalA grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery
OriginalA scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.
Featured Thumbs Gallery
OriginalA large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery
OriginalAn image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

Flip Cards Gallery
OriginalA grid of cards that flip in 3D on hover to reveal a caption and details on the back.

