Web InnoventixFreeCode

Lightbox Gallery

Original · free

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

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

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

type Shot = {
  src: string;
  title: string;
  category: string;
  location: string;
  span: string;
};

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal light",
    category: "Landscape",
    location: "Big Sur, California",
    span: "sm:col-span-2 sm:row-span-2",
  },
  {
    src: "/img/gallery/g07.webp",
    title: "Studio 04",
    category: "Portrait",
    location: "Lisbon Atelier",
    span: "sm:row-span-2",
  },
  {
    src: "/img/gallery/g12.webp",
    title: "Quiet harvest",
    category: "Nature",
    location: "Tuscan Hills",
    span: "",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Concrete lines",
    category: "Architecture",
    location: "Rotterdam",
    span: "",
  },
  {
    src: "/img/gallery/g21.webp",
    title: "Morning market",
    category: "Street",
    location: "Marrakech Medina",
    span: "sm:col-span-2",
  },
  {
    src: "/img/gallery/g05.webp",
    title: "Still water",
    category: "Landscape",
    location: "Lake Bled",
    span: "sm:row-span-2",
  },
  {
    src: "/img/gallery/g26.webp",
    title: "Golden hour",
    category: "Portrait",
    location: "Andalusia",
    span: "",
  },
  {
    src: "/img/gallery/g31.webp",
    title: "Desert fold",
    category: "Nature",
    location: "Wadi Rum",
    span: "sm:col-span-2",
  },
  {
    src: "/img/gallery/g14.webp",
    title: "Glass atrium",
    category: "Architecture",
    location: "Copenhagen",
    span: "",
  },
  {
    src: "/img/gallery/g09.webp",
    title: "Late ferry",
    category: "Street",
    location: "Istanbul",
    span: "sm:row-span-2",
  },
  {
    src: "/img/gallery/g33.webp",
    title: "Pine ridge",
    category: "Landscape",
    location: "Dolomites",
    span: "",
  },
  {
    src: "/img/gallery/g22.webp",
    title: "Studio 11",
    category: "Portrait",
    location: "Berlin Loft",
    span: "sm:col-span-2",
  },
];

export default function GalleryLightbox() {
  const [openAt, setOpenAt] = useState<number | null>(null);
  const isOpen = openAt !== null;
  const closeBtnRef = useRef<HTMLButtonElement | null>(null);
  const triggerRef = useRef<HTMLButtonElement | null>(null);

  const open = useCallback((index: number, el: HTMLButtonElement) => {
    triggerRef.current = el;
    setOpenAt(index);
  }, []);

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

  const go = useCallback((dir: 1 | -1) => {
    setOpenAt((prev) => {
      if (prev === null) return prev;
      return (prev + dir + SHOTS.length) % SHOTS.length;
    });
  }, []);

  useEffect(() => {
    if (!isOpen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowRight") go(1);
      else if (e.key === "ArrowLeft") go(-1);
    };
    window.addEventListener("keydown", onKey);
    const { overflow } = document.body.style;
    document.body.style.overflow = "hidden";
    closeBtnRef.current?.focus();
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = overflow;
    };
  }, [isOpen, close, go]);

  const active = openAt !== null ? SHOTS[openAt] : null;

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-5 py-20 text-neutral-900 sm:px-8 lg:py-28 dark:bg-neutral-950 dark:text-neutral-100">
      <style>{`
        @keyframes glbx-rise {
          from { opacity: 0; transform: translateY(24px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .glbx-rise { animation: glbx-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .glbx-rise { animation: none; }
        }
      `}</style>

      {/* Ambient wash */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-32 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/40 via-violet-300/30 to-emerald-200/40 blur-3xl dark:from-indigo-600/20 dark:via-violet-600/15 dark:to-emerald-500/10"
      />

      <div className="relative mx-auto max-w-6xl">
        <div className="glbx-rise mb-10 flex flex-col gap-4 sm:mb-14 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-neutral-500 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-neutral-400">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Field Notes
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl">
              Frames from the road
            </h2>
          </div>
          <p className="max-w-sm text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
            A wandering set of light, place and stillness. Tap any frame to view
            it full-screen and step through the collection.
          </p>
        </div>

        {/* Thumbnail grid */}
        <div className="grid auto-rows-[180px] grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4">
          {SHOTS.map((shot, i) => (
            <motion.button
              key={shot.src}
              type="button"
              onClick={(e) => open(i, e.currentTarget)}
              initial={{ opacity: 0, y: 18 }}
              whileInView={{ opacity: 1, y: 0 }}
              viewport={{ once: true, margin: "-40px" }}
              transition={{
                duration: 0.5,
                delay: (i % 4) * 0.06,
                ease: [0.16, 1, 0.3, 1],
              }}
              aria-label={`Open ${shot.title}, ${shot.category}`}
              className={`group relative overflow-hidden rounded-2xl bg-neutral-200 outline-none ring-indigo-500/70 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-neutral-800 dark:ring-offset-neutral-950 ${shot.span}`}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={shot.src}
                alt={`${shot.title} — ${shot.category} photograph taken in ${shot.location}`}
                loading="lazy"
                draggable={false}
                className="h-full w-full object-cover transition-transform duration-[900ms] ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.06]"
              />
              <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent opacity-70 transition-opacity duration-500 group-hover:opacity-90" />
              <div className="absolute inset-x-0 bottom-0 translate-y-1 p-3 text-left transition-transform duration-500 group-hover:translate-y-0 sm:p-4">
                <p className="text-[0.65rem] font-medium uppercase tracking-[0.16em] text-white/70">
                  {shot.category}
                </p>
                <p className="mt-0.5 text-sm font-semibold text-white drop-shadow-sm sm:text-base">
                  {shot.title}
                </p>
              </div>
              <span
                aria-hidden
                className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/15 text-white opacity-0 backdrop-blur-md transition-all duration-500 group-hover:opacity-100"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  className="h-4 w-4"
                  stroke="currentColor"
                  strokeWidth={2}
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"
                  />
                </svg>
              </span>
            </motion.button>
          ))}
        </div>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {isOpen && active && (
          <motion.div
            role="dialog"
            aria-modal="true"
            aria-label={`${active.title}, image ${(openAt ?? 0) + 1} of ${SHOTS.length}`}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.25 }}
            className="fixed inset-0 z-50 flex flex-col bg-neutral-950/80 backdrop-blur-xl"
            onClick={close}
          >
            {/* Top bar */}
            <div
              className="flex items-center justify-between px-5 py-4 sm:px-8"
              onClick={(e) => e.stopPropagation()}
            >
              <div className="text-white">
                <p className="text-[0.65rem] font-medium uppercase tracking-[0.18em] text-white/60">
                  {active.category} · {String((openAt ?? 0) + 1).padStart(2, "0")}
                  <span className="text-white/35">
                    {" "}
                    / {String(SHOTS.length).padStart(2, "0")}
                  </span>
                </p>
                <p className="mt-0.5 text-base font-semibold sm:text-lg">
                  {active.title}
                </p>
              </div>
              <button
                ref={closeBtnRef}
                type="button"
                onClick={close}
                aria-label="Close lightbox"
                className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/70 transition hover:bg-white/20 focus-visible:ring-2"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  className="h-5 w-5"
                  stroke="currentColor"
                  strokeWidth={2}
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M6 6l12 12M18 6L6 18"
                  />
                </svg>
              </button>
            </div>

            {/* Stage */}
            <div
              className="relative flex flex-1 items-center justify-center px-4 pb-4 sm:px-20"
              onClick={(e) => e.stopPropagation()}
            >
              <button
                type="button"
                onClick={() => go(-1)}
                aria-label="Previous image"
                className="absolute left-2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/70 transition hover:bg-white/20 focus-visible:ring-2 sm:left-6"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  className="h-6 w-6"
                  stroke="currentColor"
                  strokeWidth={2}
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M15 6l-6 6 6 6"
                  />
                </svg>
              </button>

              <AnimatePresence mode="wait">
                <motion.figure
                  key={active.src}
                  initial={{ opacity: 0, scale: 0.97 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.98 }}
                  transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
                  className="flex max-h-full max-w-full flex-col items-center"
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={active.src}
                    alt={`${active.title} — ${active.category} photograph taken in ${active.location}`}
                    loading="lazy"
                    draggable={false}
                    className="max-h-[72vh] w-auto max-w-full rounded-xl object-contain shadow-2xl shadow-black/50 ring-1 ring-white/10"
                  />
                  <figcaption className="mt-4 text-center text-sm text-white/70">
                    {active.location}
                  </figcaption>
                </motion.figure>
              </AnimatePresence>

              <button
                type="button"
                onClick={() => go(1)}
                aria-label="Next image"
                className="absolute right-2 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/70 transition hover:bg-white/20 focus-visible:ring-2 sm:right-6"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  className="h-6 w-6"
                  stroke="currentColor"
                  strokeWidth={2}
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M9 6l6 6-6 6"
                  />
                </svg>
              </button>
            </div>

            {/* Thumbnail strip */}
            <div
              className="flex items-center justify-center gap-2 overflow-x-auto px-5 pb-6 pt-2 sm:px-8"
              onClick={(e) => e.stopPropagation()}
            >
              {SHOTS.map((shot, i) => (
                <button
                  key={shot.src}
                  type="button"
                  onClick={() => setOpenAt(i)}
                  aria-label={`View ${shot.title}`}
                  aria-current={i === openAt}
                  className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg outline-none ring-white/80 transition focus-visible:ring-2 ${
                    i === openAt
                      ? "ring-2 ring-white"
                      : "opacity-50 hover:opacity-90"
                  }`}
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={shot.src}
                    alt={`Thumbnail of ${shot.title}`}
                    loading="lazy"
                    draggable={false}
                    className="h-full w-full object-cover"
                  />
                </button>
              ))}
            </div>
          </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.

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.

Flip Cards Gallery

Flip Cards Gallery

Original

A grid of cards that flip in 3D on hover to reveal a caption and details on the back.