Spotlight Cursor Gallery
Original · freeA dark image grid that a cursor-following spotlight reveals.
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-spotlight-cursor.json"use client";
import { type MouseEvent, useCallback, useRef, useState } from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
} from "motion/react";
interface GalleryItem {
src: string;
alt: string;
title: string;
category: string;
location: string;
aspect: string;
}
const ITEMS: GalleryItem[] = [
{
src: "/img/gallery/g03.webp",
alt: "Sunlit concrete stairwell spiraling upward inside a brutalist museum",
title: "Spiral Ascent",
category: "Architecture",
location: "Lisbon, Portugal",
aspect: "aspect-[3/4]",
},
{
src: "/img/gallery/g11.webp",
alt: "Foggy pine ridgeline at dawn seen from a mountain overlook",
title: "Ridge at First Light",
category: "Landscape",
location: "Dolomites, Italy",
aspect: "aspect-[13/9]",
},
{
src: "/img/gallery/g07.webp",
alt: "Neon-lit rain-slicked alley reflecting shop signs at night",
title: "After the Rain",
category: "Street",
location: "Osaka, Japan",
aspect: "aspect-square",
},
{
src: "/img/gallery/g19.webp",
alt: "Minimal white archways casting long geometric shadows on a plaza",
title: "Arch Rhythm",
category: "Architecture",
location: "Seville, Spain",
aspect: "aspect-[3/4]",
},
{
src: "/img/gallery/g22.webp",
alt: "Turquoise glacial lake framed by dark granite cliffs",
title: "Glacier Mirror",
category: "Landscape",
location: "Banff, Canada",
aspect: "aspect-[13/9]",
},
{
src: "/img/gallery/g27.webp",
alt: "Portrait of a ceramicist shaping clay on a spinning wheel",
title: "Hands & Clay",
category: "Portrait",
location: "Studio No. 4",
aspect: "aspect-square",
},
{
src: "/img/gallery/g14.webp",
alt: "Golden desert dunes rippling under low afternoon sun",
title: "Wind Lines",
category: "Landscape",
location: "Merzouga, Morocco",
aspect: "aspect-[3/4]",
},
{
src: "/img/gallery/g31.webp",
alt: "Detail of a curved glass facade catching a warm sunset gradient",
title: "Glass Gradient",
category: "Architecture",
location: "Rotterdam, NL",
aspect: "aspect-[13/9]",
},
{
src: "/img/gallery/g34.webp",
alt: "Lone cyclist crossing an empty modernist bridge at blue hour",
title: "Blue Hour Crossing",
category: "Street",
location: "Bilbao, Spain",
aspect: "aspect-square",
},
];
export default function GallerySpotlightCursor() {
const containerRef = useRef<HTMLDivElement | null>(null);
const reduceMotion = useReducedMotion();
const [revealAll, setRevealAll] = useState(false);
const [active, setActive] = useState(false);
const x = useMotionValue(480);
const y = useMotionValue(360);
const radius = useMotionValue(230);
const springConfig = { stiffness: 320, damping: 42, mass: 0.7 };
const sx = useSpring(x, springConfig);
const sy = useSpring(y, springConfig);
const sr = useSpring(radius, { stiffness: 220, damping: 30 });
const mask = useMotionTemplate`radial-gradient(circle ${sr}px at ${sx}px ${sy}px, transparent 0%, transparent 40%, rgba(2,6,23,0.72) 66%, rgba(2,6,23,0.94) 100%)`;
const glowMask = useMotionTemplate`radial-gradient(circle ${sr}px at ${sx}px ${sy}px, rgba(0,0,0,0.55) 0%, transparent 62%)`;
const handleMove = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
},
[x, y],
);
const handleEnter = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
x.jump(e.clientX - rect.left);
y.jump(e.clientY - rect.top);
setActive(true);
},
[x, y],
);
const overlayHidden = reduceMotion || revealAll;
return (
<section
aria-label="Photography gallery revealed by a cursor spotlight"
className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 lg:px-12 lg:py-28 dark:bg-slate-950 dark:text-slate-100"
>
<style>{`
@keyframes gsc-rise {
from { opacity: 0; transform: translateY(22px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes gsc-hint {
0%, 100% { opacity: 0.35; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.15); }
}
.gsc-rise { animation: gsc-rise 0.7s cubic-bezier(0.22,1,0.36,1) both; }
.gsc-hint-dot { animation: gsc-hint 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.gsc-rise, .gsc-hint-dot { animation: none !important; }
}
`}</style>
{/* ambient background glow */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-60"
style={{
backgroundImage:
"radial-gradient(60rem 40rem at 15% -10%, rgba(99,102,241,0.10), transparent 60%), radial-gradient(55rem 40rem at 100% 110%, rgba(16,185,129,0.10), transparent 55%)",
}}
/>
<div className="relative mx-auto max-w-6xl">
<header className="mb-12 flex flex-col gap-6 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/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-indigo-300">
<span className="gsc-hint-dot h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Concrete & Light
</span>
<h2 className="mt-5 text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl dark:text-white">
Move to reveal
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
A field notebook of architecture, landscape and street work. The
frames sit in the dark until your cursor lets the light in.
</p>
</div>
<button
type="button"
onClick={() => setRevealAll((v) => !v)}
aria-pressed={revealAll}
className="group inline-flex shrink-0 items-center gap-2 rounded-full border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 shadow-sm transition hover:border-indigo-400 hover:text-indigo-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-white/15 dark:bg-white/5 dark:text-slate-200 dark:hover:border-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-950"
>
<span
aria-hidden
className={`h-2 w-2 rounded-full transition-colors ${
revealAll
? "bg-emerald-500"
: "bg-slate-400 group-hover:bg-indigo-500 dark:bg-slate-500"
}`}
/>
{revealAll ? "Spotlight off" : "Reveal all"}
</button>
</header>
<div
ref={containerRef}
onMouseMove={overlayHidden ? undefined : handleMove}
onMouseEnter={overlayHidden ? undefined : handleEnter}
onMouseLeave={() => setActive(false)}
onMouseDown={() => radius.set(320)}
onMouseUp={() => radius.set(230)}
className="relative rounded-3xl"
>
{/* masonry grid */}
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3 [&>figure]:mb-4">
{ITEMS.map((item, i) => (
<figure
key={item.src}
tabIndex={0}
aria-label={`${item.title} — ${item.category}, ${item.location}`}
className="gsc-rise group relative block break-inside-avoid overflow-hidden rounded-2xl border border-slate-200/70 bg-slate-200 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-white/10 dark:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
style={{ animationDelay: `${i * 70}ms` }}
>
<div className={`relative w-full ${item.aspect}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.src}
alt={item.alt}
loading="lazy"
draggable={false}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-out will-change-transform group-hover:scale-[1.04] group-focus-visible:scale-[1.04]"
/>
</div>
{/* caption, always available for keyboard/focus */}
<figcaption className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-2 bg-gradient-to-t from-slate-950/85 via-slate-950/35 to-transparent p-4 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100">
<span className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-indigo-300">
{item.category}
</span>
<div className="mt-1 flex items-baseline justify-between gap-3">
<h3 className="text-base font-semibold text-white">
{item.title}
</h3>
<span className="shrink-0 text-xs text-slate-300">
{item.location}
</span>
</div>
</figcaption>
</figure>
))}
</div>
{/* dark spotlight overlay — desaturates + darkens everything except the cursor */}
<motion.div
aria-hidden
style={{
maskImage: mask,
WebkitMaskImage: mask,
opacity: overlayHidden ? 0 : 1,
}}
className="pointer-events-none absolute inset-0 z-10 rounded-3xl bg-slate-950/25 backdrop-grayscale backdrop-brightness-[0.42] transition-opacity duration-500 [backdrop-filter:grayscale(1)_brightness(0.42)_contrast(1.05)]"
/>
{/* warm glow riding the spotlight edge */}
<motion.div
aria-hidden
style={{
maskImage: glowMask,
WebkitMaskImage: glowMask,
opacity: overlayHidden || !active ? 0 : 0.5,
}}
className="pointer-events-none absolute inset-0 z-20 rounded-3xl bg-[radial-gradient(circle_at_center,rgba(129,140,248,0.28),transparent)] mix-blend-screen transition-opacity duration-300"
/>
{/* hint pill while idle */}
<div
className={`pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 transition-opacity duration-500 ${
overlayHidden || active ? "opacity-0" : "opacity-100"
}`}
>
<span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-slate-950/70 px-4 py-2 text-xs font-medium text-slate-200 backdrop-blur">
<span className="gsc-hint-dot h-1.5 w-1.5 rounded-full bg-indigo-400" />
Hover to light the frames
</span>
</div>
</div>
<p className="mt-8 text-center text-xs text-slate-500 dark:text-slate-500">
9 selected frames · 2023–2026 ·{" "}
<span className="text-slate-400 dark:text-slate-600">
prefers-reduced-motion shows every frame lit
</span>
</p>
</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.

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.

