Web InnoventixFreeCode

Mosaic Gallery

Original · free

A tight, gapless mosaic of mixed-size image tiles with a hover zoom.

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

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

type Shape = "portrait" | "landscape" | "square" | "feature";

interface Photo {
  src: string;
  alt: string;
  title: string;
  category: string;
  location: string;
  shape: Shape;
}

const PHOTOS: Photo[] = [
  {
    src: "/img/gallery/g07.webp",
    alt: "Sunlit atrium of a concrete brutalist library seen from the ground floor",
    title: "Reading Light",
    category: "Architecture",
    location: "Rotterdam, NL",
    shape: "feature",
  },
  {
    src: "/img/gallery/g03.webp",
    alt: "A lone hiker on a narrow ridge above a sea of morning fog",
    title: "Above the Fog",
    category: "Landscape",
    location: "Dolomites, IT",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g14.webp",
    alt: "Neon reflections on wet asphalt in a quiet night market alley",
    title: "After the Rain",
    category: "Street",
    location: "Osaka, JP",
    shape: "landscape",
  },
  {
    src: "/img/gallery/g22.webp",
    alt: "Close portrait of a ceramicist with clay-dusted hands beside a wheel",
    title: "The Maker",
    category: "Portrait",
    location: "Studio 4",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g09.webp",
    alt: "Overhead view of a breakfast spread on a warm oak table",
    title: "Slow Morning",
    category: "Still Life",
    location: "Home",
    shape: "square",
  },
  {
    src: "/img/gallery/g18.webp",
    alt: "Emerald terraced rice fields curving down a misted valley",
    title: "Terraces",
    category: "Landscape",
    location: "Bali, ID",
    shape: "landscape",
  },
  {
    src: "/img/gallery/g27.webp",
    alt: "Spiral staircase photographed straight down into a warm stairwell",
    title: "Descent",
    category: "Architecture",
    location: "Lisbon, PT",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "A vendor arranging fresh flowers under a striped awning at dawn",
    title: "First Bloom",
    category: "Street",
    location: "Marché, FR",
    shape: "square",
  },
  {
    src: "/img/gallery/g31.webp",
    alt: "Long-exposure of turquoise coastline with soft blurred surf at dusk",
    title: "Blue Hour",
    category: "Landscape",
    location: "Amalfi, IT",
    shape: "feature",
  },
  {
    src: "/img/gallery/g05.webp",
    alt: "Minimal interior corner with a single chair in raking afternoon light",
    title: "Quiet Corner",
    category: "Interior",
    location: "Copenhagen, DK",
    shape: "portrait",
  },
  {
    src: "/img/gallery/g24.webp",
    alt: "Aerial shadows of pine trees stretched across fresh snow",
    title: "Long Shadows",
    category: "Aerial",
    location: "Hokkaido, JP",
    shape: "landscape",
  },
  {
    src: "/img/gallery/g16.webp",
    alt: "Detail of woven textile threads in deep indigo and rust tones",
    title: "Warp & Weft",
    category: "Detail",
    location: "Oaxaca, MX",
    shape: "square",
  },
];

const SPAN: Record<Shape, string> = {
  feature: "col-span-2 row-span-2 sm:col-span-2 sm:row-span-3 lg:col-span-2 lg:row-span-3",
  portrait: "col-span-1 row-span-2 sm:row-span-3 lg:row-span-3",
  landscape: "col-span-2 row-span-1 sm:col-span-2 sm:row-span-2 lg:col-span-2 lg:row-span-2",
  square: "col-span-1 row-span-2 sm:row-span-2 lg:row-span-2",
};

export default function GalleryMosaic() {
  const [active, setActive] = useState<number | null>(null);
  const closeRef = useRef<HTMLButtonElement | null>(null);
  const lastFocus = useRef<HTMLElement | null>(null);
  const gridRef = useRef<HTMLDivElement | null>(null);
  const inView = useInView(gridRef, { once: true, margin: "-80px" });

  const open = useCallback((i: number) => {
    lastFocus.current = document.activeElement as HTMLElement;
    setActive(i);
  }, []);

  const close = useCallback(() => {
    setActive(null);
    lastFocus.current?.focus?.();
  }, []);

  const step = useCallback((dir: number) => {
    setActive((cur) => {
      if (cur === null) return cur;
      return (cur + dir + PHOTOS.length) % PHOTOS.length;
    });
  }, []);

  useEffect(() => {
    if (active === null) return;
    closeRef.current?.focus();
    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 : PHOTOS[active];

  return (
    <section className="relative w-full overflow-hidden bg-white px-4 py-20 sm:px-6 sm:py-24 lg:px-8 dark:bg-neutral-950">
      <style>{`
        @keyframes gmmoz-rise {
          from { opacity: 0; transform: translateY(22px) scale(0.985); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes gmmoz-sheen {
          0% { transform: translateX(-120%) skewX(-12deg); }
          100% { transform: translateX(220%) skewX(-12deg); }
        }
        @media (prefers-reduced-motion: reduce) {
          .gmmoz-rise, .gmmoz-sheen-el { animation: none !important; }
          .gmmoz-zoom { transition: none !important; }
        }
      `}</style>

      {/* ambient wash */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 bg-gradient-to-b from-indigo-100/60 via-transparent to-transparent blur-2xl dark:from-indigo-500/10"
      />

      <div className="mx-auto max-w-7xl">
        <div className="mb-10 flex flex-col gap-5 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-2xl">
            <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-400/10 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 text-neutral-900 sm:text-4xl lg:text-5xl dark:text-white">
              The Mosaic Archive
            </h2>
            <p className="mt-3 text-base leading-relaxed text-neutral-600 sm:text-lg dark:text-neutral-400">
              A gapless wall of frames from twelve places, each holding still.
              Hover to lean in; select any tile to open it full-frame.
            </p>
          </div>
          <p className="shrink-0 text-sm font-medium text-neutral-400 dark:text-neutral-500">
            {PHOTOS.length} photographs
          </p>
        </div>

        <div
          ref={gridRef}
          className="grid auto-rows-[112px] grid-cols-2 gap-0 overflow-hidden rounded-2xl border border-neutral-200 [grid-auto-flow:dense] sm:auto-rows-[130px] sm:grid-cols-3 lg:auto-rows-[150px] lg:grid-cols-4 dark:border-neutral-800"
        >
          {PHOTOS.map((p, i) => (
            <button
              key={p.src}
              type="button"
              onClick={() => open(i)}
              aria-label={`Open ${p.title} — ${p.category}, ${p.location}`}
              style={{
                animation: inView
                  ? `gmmoz-rise 0.6s cubic-bezier(0.22,1,0.36,1) ${i * 55}ms both`
                  : undefined,
                opacity: inView ? undefined : 0,
              }}
              className={`gmmoz-rise group relative isolate block overflow-hidden ring-1 ring-inset ring-white/40 outline-none focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:ring-black/30 ${SPAN[p.shape]}`}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={p.src}
                alt={p.alt}
                loading="lazy"
                draggable={false}
                className="gmmoz-zoom h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform group-hover:scale-[1.09] group-focus-visible:scale-[1.09]"
              />

              {/* darkening + sheen on hover */}
              <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/80 via-neutral-950/10 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-95 group-focus-visible:opacity-95" />
              <div className="pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-300 group-hover:opacity-100">
                <span className="gmmoz-sheen-el absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent [animation:gmmoz-sheen_1.1s_ease-out]" />
              </div>

              {/* caption */}
              <div className="absolute inset-x-0 bottom-0 translate-y-2 p-3 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 sm:p-4">
                <p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-indigo-300">
                  {p.category}
                </p>
                <p className="mt-0.5 truncate text-sm font-semibold text-white sm:text-base">
                  {p.title}
                </p>
                <p className="truncate text-xs text-white/70">{p.location}</p>
              </div>
            </button>
          ))}
        </div>
      </div>

      <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 p-4 sm:p-8"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.25 }}
          >
            <button
              type="button"
              aria-label="Close gallery"
              onClick={close}
              className="absolute inset-0 -z-10 cursor-default bg-neutral-950/85 backdrop-blur-md"
            />

            <button
              ref={closeRef}
              type="button"
              onClick={close}
              aria-label="Close"
              className="absolute right-4 top-4 z-20 rounded-full border border-white/15 bg-white/10 p-2 text-white/90 outline-none transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:right-6 sm:top-6"
            >
              <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
                <path d="M6 6l12 12M18 6L6 18" />
              </svg>
            </button>

            <button
              type="button"
              onClick={() => step(-1)}
              aria-label="Previous photo"
              className="absolute left-3 top-1/2 z-20 -translate-y-1/2 rounded-full border border-white/15 bg-white/10 p-2.5 text-white/90 outline-none transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:left-6"
            >
              <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <path d="M15 5l-7 7 7 7" />
              </svg>
            </button>
            <button
              type="button"
              onClick={() => step(1)}
              aria-label="Next photo"
              className="absolute right-3 top-1/2 z-20 -translate-y-1/2 rounded-full border border-white/15 bg-white/10 p-2.5 text-white/90 outline-none transition hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-indigo-400 sm:right-6"
            >
              <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <path d="M9 5l7 7-7 7" />
              </svg>
            </button>

            <motion.figure
              key={current.src}
              initial={{ opacity: 0, scale: 0.96, y: 12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.98 }}
              transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
              className="relative flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-neutral-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 bg-neutral-800 object-contain"
              />
              <figcaption className="flex items-end justify-between gap-4 border-t border-white/10 bg-neutral-900 px-5 py-4">
                <div className="min-w-0">
                  <p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-indigo-300">
                    {current.category}
                  </p>
                  <p className="mt-0.5 truncate text-base font-semibold text-white">
                    {current.title}
                  </p>
                  <p className="truncate text-sm text-white/60">{current.location}</p>
                </div>
                <span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs font-medium tabular-nums text-white/70">
                  {(active ?? 0) + 1} / {PHOTOS.length}
                </span>
              </figcaption>
            </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.