Web InnoventixFreeCode

Spotlight Cursor Gallery

Original · free

A dark image grid that a cursor-following spotlight reveals.

byWeb InnoventixReact + Tailwind
spotlightcursorgalleryimages
Open

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
gallery-spotlight-cursor.tsx
"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 &amp; 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

motion

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 quote

Similar components

Browse all →
Hover Caption Gallery

Hover Caption Gallery

Original

A responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery

Masonry Gallery

Original

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

Filterable Gallery

Filterable Gallery

Original

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

Tilt Cards Gallery

Tilt Cards Gallery

Original

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

Lightbox Gallery

Lightbox Gallery

Original

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

Expanding Strip Gallery

Expanding Strip Gallery

Original

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

Marquee Gallery

Marquee Gallery

Original

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

Hover Zoom Overlay Gallery

Hover Zoom Overlay Gallery

Original

A grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery

Polaroid Stack Gallery

Original

A scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.

Featured Thumbs Gallery

Featured Thumbs Gallery

Original

A large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

Carousel Gallery

Carousel Gallery

Original

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

Duotone Hover Gallery

Duotone Hover Gallery

Original

A grid where images sit under a coloured duotone wash that clears to full colour on hover.