Magnetic Tiles Gallery
Original · freeImage tiles that magnetically nudge toward the cursor and scale slightly.
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-magnetic-tiles.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
} from "motion/react";
type Tile = {
src: string;
alt: string;
title: string;
category: string;
location: string;
span: "tall" | "wide" | "square";
};
const TILES: Tile[] = [
{
src: "/img/gallery/g03.webp",
alt: "Weathered watchmaker inspecting a movement through a loupe at his bench",
title: "The Watchmaker",
category: "Portrait",
location: "Geneva, CH",
span: "tall",
},
{
src: "/img/gallery/g05.webp",
alt: "Snow-dusted alpine ridgeline under a pale winter sky",
title: "Alpine Silence",
category: "Landscape",
location: "Zermatt, CH",
span: "wide",
},
{
src: "/img/gallery/g08.webp",
alt: "Minimal still life of blue ceramics arranged on a stone shelf",
title: "Still Life in Blue",
category: "Studio",
location: "Lisbon, PT",
span: "square",
},
{
src: "/img/gallery/g01.webp",
alt: "Vaulted cathedral ceiling with light streaming through stained glass",
title: "Cathedral of Light",
category: "Architecture",
location: "Cologne, DE",
span: "tall",
},
{
src: "/img/gallery/g04.webp",
alt: "Rain-slicked alleyway lit by neon signage after dark",
title: "Neon Alleyway",
category: "Street",
location: "Osaka, JP",
span: "square",
},
{
src: "/img/gallery/g07.webp",
alt: "Vendor surrounded by open sacks of saffron and spices at a market",
title: "Saffron Market",
category: "Travel",
location: "Marrakech, MA",
span: "wide",
},
{
src: "/img/gallery/g06.webp",
alt: "Brutalist concrete facade forming repeating geometric shadows",
title: "Concrete Geometry",
category: "Architecture",
location: "São Paulo, BR",
span: "tall",
},
{
src: "/img/gallery/g09.webp",
alt: "Rolling sand dunes catching low golden light at dusk",
title: "Dunes at Dusk",
category: "Landscape",
location: "Sossusvlei, NA",
span: "wide",
},
{
src: "/img/gallery/g10.webp",
alt: "Figure with an umbrella crossing a rooftop in heavy rain",
title: "Rooftop Rain",
category: "Street",
location: "Seoul, KR",
span: "square",
},
{
src: "/img/gallery/g02.webp",
alt: "Long-exposure seascape of tide pulling over dark coastal rocks",
title: "Morning Tide",
category: "Seascape",
location: "Nazaré, PT",
span: "tall",
},
];
const SPAN_CLASS: Record<Tile["span"], string> = {
tall: "sm:row-span-2 aspect-[4/5]",
wide: "sm:col-span-2 aspect-[16/10]",
square: "aspect-square",
};
function MagneticTile({
tile,
index,
reduced,
onOpen,
}: {
tile: Tile;
index: number;
reduced: boolean;
onOpen: (i: number) => void;
}) {
const ref = useRef<HTMLButtonElement>(null);
const springCfg = { stiffness: 220, damping: 18, mass: 0.4 };
const x = useSpring(useMotionValue(0), springCfg);
const y = useSpring(useMotionValue(0), springCfg);
const scale = useSpring(1, { stiffness: 260, damping: 22 });
const rotate = useSpring(0, { stiffness: 200, damping: 20 });
const glowX = useMotionValue(50);
const glowY = useMotionValue(50);
const glow = useMotionTemplate`radial-gradient(220px circle at ${glowX}% ${glowY}%, rgba(255,255,255,0.22), transparent 62%)`;
const handleMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
if (reduced) return;
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
x.set(dx * 0.16);
y.set(dy * 0.16);
rotate.set((dx / rect.width) * 4);
glowX.set(((e.clientX - rect.left) / rect.width) * 100);
glowY.set(((e.clientY - rect.top) / rect.height) * 100);
},
[reduced, x, y, rotate, glowX, glowY],
);
const handleEnter = useCallback(() => {
if (reduced) return;
scale.set(1.05);
}, [reduced, scale]);
const handleLeave = useCallback(() => {
x.set(0);
y.set(0);
rotate.set(0);
scale.set(1);
glowX.set(50);
glowY.set(50);
}, [x, y, rotate, scale, glowX, glowY]);
return (
<motion.button
ref={ref}
type="button"
onPointerMove={handleMove}
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
onFocus={handleEnter}
onBlur={handleLeave}
onClick={() => onOpen(index)}
style={reduced ? undefined : { x, y, scale, rotate }}
initial={reduced ? false : { opacity: 0, y: 26 }}
whileInView={reduced ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, delay: (index % 4) * 0.06, ease: [0.22, 1, 0.36, 1] }}
aria-label={`Open ${tile.title}, ${tile.category}, ${tile.location}`}
className={[
"gmt-tile group relative overflow-hidden rounded-2xl text-left",
"border border-slate-200/70 bg-slate-100 shadow-sm",
"outline-none ring-indigo-500/60 transition-shadow duration-300",
"hover:shadow-2xl hover:shadow-indigo-500/10 focus-visible:ring-2 focus-visible:ring-offset-2",
"focus-visible:ring-offset-slate-50 dark:border-white/10 dark:bg-zinc-900",
"dark:shadow-black/40 dark:hover:shadow-black/60 dark:focus-visible:ring-offset-zinc-950",
SPAN_CLASS[tile.span],
].join(" ")}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={tile.src}
alt={tile.alt}
loading="lazy"
draggable={false}
className="gmt-img h-full w-full select-none object-cover transition-transform duration-[700ms] ease-out group-hover:scale-[1.08]"
/>
{/* cursor spotlight */}
{!reduced && (
<motion.span
aria-hidden
style={{ backgroundImage: glow }}
className="pointer-events-none absolute inset-0 opacity-0 mix-blend-soft-light transition-opacity duration-300 group-hover:opacity-100"
/>
)}
{/* legibility gradient */}
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-slate-950/85 via-slate-950/25 to-transparent"
/>
{/* caption */}
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex translate-y-1 flex-col gap-1 p-4 transition-transform duration-300 group-hover:translate-y-0">
<span className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-white/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-white backdrop-blur-sm ring-1 ring-white/25">
{tile.category}
</span>
<span className="text-[11px] font-medium text-white/70">{tile.location}</span>
</span>
<span className="text-base font-semibold leading-tight text-white drop-shadow-sm">
{tile.title}
</span>
</span>
{/* corner cue */}
<span
aria-hidden
className="absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full bg-white/15 text-white opacity-0 backdrop-blur-md ring-1 ring-white/25 transition-all duration-300 group-hover:opacity-100"
>
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4">
<path
d="M8 3H3v5m9-5h5v5m0 4v5h-5M8 17H3v-5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</motion.button>
);
}
export default function GalleryMagneticTiles() {
const reduced = useReducedMotion() ?? false;
const [active, setActive] = useState<number | null>(null);
const open = useCallback((i: number) => setActive(i), []);
const close = useCallback(() => setActive(null), []);
const step = useCallback(
(dir: 1 | -1) =>
setActive((prev) =>
prev === null ? prev : (prev + dir + TILES.length) % TILES.length,
),
[],
);
useEffect(() => {
if (active === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
else if (e.key === "ArrowRight") step(1);
else if (e.key === "ArrowLeft") step(-1);
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [active, close, step]);
const current = active === null ? null : TILES[active];
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-zinc-950 dark:text-slate-100">
<style>{`
@keyframes gmt-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -14px, 0); }
}
@keyframes gmt-pop {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
.gmt-orb { animation: gmt-float 12s ease-in-out infinite; }
.gmt-orb-2 { animation: gmt-float 15s ease-in-out infinite reverse; }
@media (prefers-reduced-motion: reduce) {
.gmt-orb, .gmt-orb-2 { animation: none; }
.gmt-img { transition: none !important; }
}
`}</style>
{/* ambient background */}
<div
aria-hidden
className="gmt-orb pointer-events-none absolute -left-24 top-10 -z-10 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
/>
<div
aria-hidden
className="gmt-orb-2 pointer-events-none absolute -right-16 bottom-0 -z-10 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-600/15"
/>
<div className="mx-auto max-w-6xl">
<header className="mb-12 flex flex-col 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-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-[0.16em] text-indigo-600 dark:border-white/10 dark:bg-white/5 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Field Notes 2026
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">
The Magnetic Archive
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Ten frames from the road. Move your cursor across the grid, the tiles
lean toward you. Click any frame to open it full-size.
</p>
</div>
<p className="shrink-0 text-sm text-slate-500 dark:text-slate-500">
<span className="font-semibold text-slate-800 dark:text-slate-200">
{TILES.length}
</span>{" "}
selected works
</p>
</header>
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4">
{TILES.map((tile, i) => (
<MagneticTile
key={tile.src}
tile={tile}
index={i}
reduced={reduced}
onOpen={open}
/>
))}
</div>
</div>
{/* lightbox */}
<AnimatePresence>
{current && (
<motion.div
role="dialog"
aria-modal="true"
aria-label={`${current.title}, ${current.category}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/90 p-4 backdrop-blur-md sm:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
onClick={close}
>
<button
type="button"
onClick={close}
aria-label="Close"
className="absolute right-4 top-4 z-10 grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white outline-none ring-white/40 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M6 6l12 12M18 6L6 18"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
step(-1);
}}
aria-label="Previous image"
className="absolute left-3 top-1/2 z-10 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-white outline-none ring-white/40 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2 sm:left-6"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M14 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
step(1);
}}
aria-label="Next image"
className="absolute right-3 top-1/2 z-10 grid h-11 w-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-white outline-none ring-white/40 backdrop-blur-md transition hover:bg-white/20 focus-visible:ring-2 sm:right-6"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M10 6l6 6-6 6"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<motion.figure
key={current.src}
onClick={(e) => e.stopPropagation()}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="flex max-h-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-zinc-900 shadow-2xl ring-1 ring-white/10"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-full select-none object-contain"
/>
<figcaption className="flex items-center justify-between gap-4 border-t border-white/10 px-5 py-4">
<div>
<p className="text-sm font-semibold text-white">{current.title}</p>
<p className="text-xs text-slate-400">
{current.category} · {current.location}
</p>
</div>
<span className="text-xs tabular-nums text-slate-500">
{String((active ?? 0) + 1).padStart(2, "0")} / {String(TILES.length).padStart(2, "0")}
</span>
</figcaption>
</motion.figure>
</motion.div>
)}
</AnimatePresence>
</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.

Tilt Cards Gallery
OriginalA grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

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.

