Web InnoventixFreeCode

Hover Spread Gallery

Original · free

A tight row of overlapping images that fans and spreads apart, lifting the hovered one.

byWeb InnoventixReact + Tailwind
hoverspreadgalleryimages
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-hover-spread.json
gallery-hover-spread.tsx
"use client";

import { useCallback, useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";

type Photo = {
  src: string;
  title: string;
  category: string;
  alt: string;
};

const PHOTOS: Photo[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal Light",
    category: "Seascape",
    alt: "Pale morning light spreading over a calm coastline",
  },
  {
    src: "/img/gallery/g07.webp",
    title: "Studio 04",
    category: "Portrait",
    alt: "Soft studio portrait lit from a single window source",
  },
  {
    src: "/img/gallery/g12.webp",
    title: "Golden Hour",
    category: "Landscape",
    alt: "Warm low sun raking across an open field at dusk",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Quiet Harbor",
    category: "Travel",
    alt: "Still harbor water reflecting moored boats at first light",
  },
  {
    src: "/img/gallery/g22.webp",
    title: "Wildflowers",
    category: "Nature",
    alt: "Cluster of wildflowers backlit against soft green foliage",
  },
  {
    src: "/img/gallery/g27.webp",
    title: "City Grain",
    category: "Street",
    alt: "High-contrast street scene with long afternoon shadows",
  },
  {
    src: "/img/gallery/g31.webp",
    title: "Still Life",
    category: "Studio",
    alt: "Minimal still life arrangement on a textured surface",
  },
  {
    src: "/img/gallery/g34.webp",
    title: "Alpine Fog",
    category: "Mountains",
    alt: "Layered mountain ridges fading into low alpine fog",
  },
];

const SPRING = { type: "spring" as const, stiffness: 260, damping: 26, mass: 0.7 };

export default function GalleryHoverSpread() {
  const [active, setActive] = useState<number | null>(null);
  const [lightbox, setLightbox] = useState<number | null>(null);

  const center = (PHOTOS.length - 1) / 2;

  const closeLightbox = useCallback(() => setLightbox(null), []);
  const step = useCallback((dir: number) => {
    setLightbox((prev) =>
      prev === null ? prev : (prev + dir + PHOTOS.length) % PHOTOS.length
    );
  }, []);

  useEffect(() => {
    if (lightbox === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") closeLightbox();
      else if (e.key === "ArrowRight") step(1);
      else if (e.key === "ArrowLeft") step(-1);
    };
    window.addEventListener("keydown", onKey);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
    };
  }, [lightbox, closeLightbox, step]);

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-6 py-24 sm:py-28 dark:from-neutral-950 dark:to-neutral-900">
      <style>{`
        @keyframes ghs-breathe {
          0%, 100% { opacity: 0.55; transform: translateY(0); }
          50% { opacity: 1; transform: translateY(-2px); }
        }
        .ghs-hint { animation: ghs-breathe 3.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ghs-hint { animation: none; opacity: 0.85; }
        }
      `}</style>

      <div className="relative z-10 mx-auto max-w-5xl text-center">
        <p className="text-xs font-semibold uppercase tracking-[0.35em] text-indigo-600 dark:text-indigo-400">
          Collection
        </p>
        <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
          A Selected Hand
        </h2>
        <p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-neutral-400">
          Eight frames held close, dealt like cards. Hover to fan the set apart
          and lift a single frame into focus.
        </p>
      </div>

      {/* The fanned hand */}
      <div
        className="relative z-10 mx-auto mt-16 flex max-w-5xl items-end justify-center px-4 pb-8 pt-12"
        onMouseLeave={() => setActive(null)}
      >
        {PHOTOS.map((photo, i) => {
          const rel = i - center;
          const isActive = active === i;
          const spread = active !== null;

          let x = 0;
          if (spread) {
            x = rel * 42;
            if (i < active!) x -= 30;
            else if (i > active!) x += 30;
          }

          const rotate = isActive
            ? 0
            : spread
              ? rel * 8
              : rel * 4;

          const y = isActive
            ? -52
            : spread
              ? Math.abs(rel) * 12
              : Math.abs(rel) * 6;

          const scale = isActive ? 1.14 : 1;

          return (
            <motion.button
              key={photo.src}
              type="button"
              className="group relative -ml-16 shrink-0 cursor-pointer rounded-2xl outline-none first:ml-0 focus-visible:ring-4 focus-visible:ring-indigo-500/60 sm:-ml-24 md:-ml-28"
              style={{
                zIndex: isActive ? 100 : i,
                transformOrigin: "bottom center",
                marginLeft: i === 0 ? 0 : undefined,
              }}
              animate={{ x, y, rotate, scale }}
              transition={SPRING}
              onMouseEnter={() => setActive(i)}
              onFocus={() => setActive(i)}
              onBlur={() => setActive(null)}
              onClick={() => setLightbox(i)}
              aria-label={`View ${photo.title}, ${photo.category}`}
            >
              <div
                className={`relative h-44 w-32 overflow-hidden rounded-2xl border border-white/70 bg-slate-200 ring-1 ring-black/5 transition-shadow duration-300 sm:h-56 sm:w-40 md:h-64 md:w-44 dark:border-white/10 dark:bg-neutral-800 ${
                  isActive
                    ? "shadow-[0_30px_60px_-15px_rgba(15,23,42,0.55)] dark:shadow-[0_30px_60px_-15px_rgba(0,0,0,0.8)]"
                    : "shadow-[0_12px_30px_-12px_rgba(15,23,42,0.35)]"
                }`}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={photo.src}
                  alt={photo.alt}
                  loading="lazy"
                  draggable={false}
                  className="h-full w-full object-cover"
                />
                {/* Legibility veil for the caption */}
                <div
                  aria-hidden="true"
                  className={`pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent transition-opacity duration-300 ${
                    isActive ? "opacity-100" : "opacity-0 group-hover:opacity-90"
                  }`}
                />
                {/* Caption */}
                <motion.div
                  aria-hidden="true"
                  initial={false}
                  animate={{
                    opacity: isActive ? 1 : 0,
                    y: isActive ? 0 : 8,
                  }}
                  transition={{ duration: 0.25 }}
                  className="absolute inset-x-0 bottom-0 p-3 text-left"
                >
                  <p className="text-[0.6rem] font-semibold uppercase tracking-[0.2em] text-indigo-200">
                    {photo.category}
                  </p>
                  <p className="mt-0.5 text-sm font-semibold text-white drop-shadow-sm">
                    {photo.title}
                  </p>
                </motion.div>
              </div>
            </motion.button>
          );
        })}
      </div>

      <p className="ghs-hint relative z-10 mt-6 text-center text-xs font-medium uppercase tracking-[0.3em] text-slate-400 dark:text-neutral-500">
        Hover or focus to fan out
      </p>

      {/* Lightbox */}
      <AnimatePresence>
        {lightbox !== null && (
          <motion.div
            className="fixed inset-0 z-[999] flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            onClick={closeLightbox}
            role="dialog"
            aria-modal="true"
            aria-label={`${PHOTOS[lightbox].title}, enlarged`}
          >
            <motion.figure
              key={PHOTOS[lightbox].src}
              className="relative max-h-[85vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-slate-900 shadow-2xl ring-1 ring-white/10"
              initial={{ scale: 0.92, opacity: 0, y: 16 }}
              animate={{ scale: 1, opacity: 1, y: 0 }}
              exit={{ scale: 0.94, opacity: 0, y: 16 }}
              transition={SPRING}
              onClick={(e) => e.stopPropagation()}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={PHOTOS[lightbox].src}
                alt={PHOTOS[lightbox].alt}
                loading="lazy"
                draggable={false}
                className="max-h-[70vh] w-full object-contain"
              />
              <figcaption className="flex items-center justify-between gap-4 border-t border-white/10 px-6 py-4">
                <div className="text-left">
                  <p className="text-[0.65rem] font-semibold uppercase tracking-[0.25em] text-indigo-300">
                    {PHOTOS[lightbox].category}
                  </p>
                  <p className="mt-1 text-lg font-semibold text-white">
                    {PHOTOS[lightbox].title}
                  </p>
                </div>
                <span className="shrink-0 text-xs font-medium tabular-nums text-slate-400">
                  {lightbox + 1} / {PHOTOS.length}
                </span>
              </figcaption>

              {/* Controls */}
              <button
                type="button"
                onClick={() => step(-1)}
                aria-label="Previous photo"
                className="absolute left-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/25 focus-visible:ring-4 focus-visible:ring-indigo-500/60"
              >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => step(1)}
                aria-label="Next photo"
                className="absolute right-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/25 focus-visible:ring-4 focus-visible:ring-indigo-500/60"
              >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              <button
                type="button"
                onClick={closeLightbox}
                aria-label="Close"
                className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white outline-none backdrop-blur transition hover:bg-white/25 focus-visible:ring-4 focus-visible:ring-indigo-500/60"
              >
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
            </motion.figure>
          </motion.div>
        )}
      </AnimatePresence>
    </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.