Web InnoventixFreeCode

Drawer Detail Gallery

Original · free

A grid where clicking a tile opens a side drawer with the large image and details.

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

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

type Photo = {
  src: string;
  alt: string;
  title: string;
  category: string;
  location: string;
  date: string;
  camera: string;
  caption: string;
  orientation: "portrait" | "landscape" | "square";
};

const PHOTOS: Photo[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Fog rolling over a pine ridge at first light",
    title: "Blue Hour on Kestrel Ridge",
    category: "Landscape",
    location: "Cascade Range, Oregon",
    date: "Nov 2025",
    camera: "35mm · f/8 · 1/125s · ISO 200",
    caption:
      "We hiked in the dark to catch the fog before the sun burned it off. It held for exactly nine minutes.",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Portrait of a ceramicist holding a freshly thrown bowl",
    title: "Hands That Remember the Clay",
    category: "Portrait",
    location: "Studio 4, Lisbon",
    date: "Sep 2025",
    camera: "85mm · f/1.8 · 1/200s · ISO 400",
    caption:
      "Ana has thrown roughly forty thousand bowls. She says the good ones still surprise her.",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g22.webp",
    alt: "Overhead view of a market stall stacked with citrus",
    title: "Citrus, Counted Twice",
    category: "Street",
    location: "Mercado Central, Valencia",
    date: "Jan 2026",
    camera: "50mm · f/4 · 1/320s · ISO 160",
    caption:
      "Shot from a footbridge above the stall. The vendor waved, then went back to stacking.",
    orientation: "square",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Neon-lit alley reflected in a rain puddle at night",
    title: "The Alley Keeps Its Colors",
    category: "Night",
    location: "Shimokitazawa, Tokyo",
    date: "Oct 2025",
    camera: "24mm · f/2 · 1/60s · ISO 1600",
    caption:
      "Rain does half the work at night. I just waited for the puddle to go still between footsteps.",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g18.webp",
    alt: "Sand dunes carved into sharp ridgelines by wind",
    title: "Wind Draws Its Own Lines",
    category: "Landscape",
    location: "Erg Chebbi, Morocco",
    date: "Mar 2025",
    camera: "70mm · f/11 · 1/500s · ISO 100",
    caption:
      "The ridgelines redraw themselves every afternoon. This particular curve lasted one gust.",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g29.webp",
    alt: "Close-up of dew beads on a spider web at sunrise",
    title: "A Hundred Small Mirrors",
    category: "Macro",
    location: "Peak District, England",
    date: "Jun 2025",
    camera: "100mm macro · f/5.6 · 1/250s · ISO 320",
    caption:
      "Each bead holds a tiny upside-down sunrise. You only get them for about twenty minutes.",
    orientation: "square",
  },
  {
    src: "/img/gallery/g14.webp",
    alt: "A lone swimmer crossing a still turquoise lake",
    title: "One Swimmer, Whole Lake",
    category: "Adventure",
    location: "Lake Bohinj, Slovenia",
    date: "Jul 2025",
    camera: "135mm · f/5.6 · 1/800s · ISO 200",
    caption:
      "He swims it every morning before the tour boats start. The water was 14 degrees.",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g26.webp",
    alt: "Weathered blue door set into a whitewashed stone wall",
    title: "The Blue Door on Rua da Cal",
    category: "Architecture",
    location: "Óbidos, Portugal",
    date: "Apr 2025",
    camera: "40mm · f/6.3 · 1/200s · ISO 100",
    caption:
      "Seven coats of paint, the owner told me. Every winter the salt air strips one back off.",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g33.webp",
    alt: "Steam rising off a hot spring against snow-covered rocks",
    title: "Where the Ground Breathes",
    category: "Landscape",
    location: "Hveravellir, Iceland",
    date: "Feb 2026",
    camera: "28mm · f/8 · 1/160s · ISO 250",
    caption:
      "Minus eleven in the air, thirty-eight in the water. The steam never stops moving.",
    orientation: "square",
  },
];

export default function GalleryDrawerDetail() {
  const [openIndex, setOpenIndex] = useState<number | null>(null);
  const headingId = useId();
  const sectionRef = useRef<HTMLElement | null>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });

  const isOpen = openIndex !== null;
  const active = openIndex !== null ? PHOTOS[openIndex] : null;

  const close = useCallback(() => setOpenIndex(null), []);
  const goPrev = useCallback(
    () =>
      setOpenIndex((i) => (i === null ? i : (i - 1 + PHOTOS.length) % PHOTOS.length)),
    [],
  );
  const goNext = useCallback(
    () => setOpenIndex((i) => (i === null ? i : (i + 1) % PHOTOS.length)),
    [],
  );

  useEffect(() => {
    if (!isOpen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowLeft") goPrev();
      else if (e.key === "ArrowRight") goNext();
    };
    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;
    };
  }, [isOpen, close, goPrev, goNext]);

  const drawerRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    if (isOpen && drawerRef.current) drawerRef.current.focus();
  }, [isOpen]);

  return (
    <section
      ref={sectionRef}
      aria-labelledby={headingId}
      className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-20 text-slate-900 sm:px-8 lg:px-12 lg:py-28 dark:from-zinc-950 dark:via-zinc-950 dark:to-black dark:text-zinc-100"
    >
      <style>{`
        @keyframes gdd-rise {
          from { opacity: 0; transform: translateY(26px) scale(0.985); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes gdd-shimmer {
          0% { background-position: -160% 0; }
          100% { background-position: 260% 0; }
        }
        .gdd-tile {
          animation: gdd-rise 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        .gdd-accent {
          background-image: linear-gradient(
            90deg,
            transparent 0%,
            rgba(99, 102, 241, 0.55) 45%,
            rgba(139, 92, 246, 0.55) 55%,
            transparent 100%
          );
          background-size: 200% 100%;
          animation: gdd-shimmer 4.5s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .gdd-tile { animation: none !important; }
          .gdd-accent { animation: none !important; }
        }
      `}</style>

      {/* ambient glow */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="mb-12 max-w-2xl">
          <div className="mb-4 flex items-center gap-3">
            <span className="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 uppercase dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
              Field Journal
            </span>
            <span className="h-px w-16 gdd-accent rounded-full" aria-hidden="true" />
          </div>
          <h2
            id={headingId}
            className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl lg:text-5xl"
          >
            Nine frames, nine mornings
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 sm:text-lg dark:text-zinc-400">
            A year of chasing light in the field. Tap any frame to slide open the
            full shot and the story behind it.
          </p>
        </header>

        {/* masonry grid */}
        <ul
          className="columns-1 gap-4 space-y-4 sm:columns-2 sm:gap-5 sm:space-y-5 lg:columns-3"
          role="list"
        >
          {PHOTOS.map((photo, i) => (
            <li key={photo.src} className="break-inside-avoid">
              <button
                type="button"
                onClick={() => setOpenIndex(i)}
                aria-haspopup="dialog"
                aria-label={`Open details for ${photo.title}`}
                className="gdd-tile group relative block w-full overflow-hidden rounded-2xl bg-slate-200 text-left ring-1 ring-slate-900/5 transition-shadow duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white hover:shadow-xl hover:shadow-slate-900/10 dark:bg-zinc-800 dark:ring-white/10 dark:focus-visible:ring-offset-zinc-950 dark:hover:shadow-black/40"
                style={
                  inView
                    ? { animationDelay: `${Math.min(i, 8) * 70}ms` }
                    : { animation: "none", opacity: 0 }
                }
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={photo.src}
                  alt={photo.alt}
                  loading="lazy"
                  draggable={false}
                  className={`w-full ${
                    photo.orientation === "portrait"
                      ? "aspect-[4/5]"
                      : photo.orientation === "landscape"
                        ? "aspect-[13/9]"
                        : "aspect-square"
                  } object-cover transition-transform duration-[900ms] ease-out group-hover:scale-[1.05]`}
                />
                <div
                  className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/5 to-transparent opacity-90 transition-opacity duration-300 group-hover:opacity-100"
                  aria-hidden="true"
                />
                <div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 p-4 transition-transform duration-300 group-hover:translate-y-0">
                  <span className="inline-flex items-center rounded-full bg-white/15 px-2.5 py-0.5 text-[0.7rem] font-medium tracking-wide text-white uppercase backdrop-blur-sm ring-1 ring-white/20">
                    {photo.category}
                  </span>
                  <h3 className="mt-2 text-base font-semibold text-white drop-shadow-sm">
                    {photo.title}
                  </h3>
                  <p className="mt-0.5 text-xs text-white/70">{photo.location}</p>
                </div>
                <span
                  className="pointer-events-none absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/85 text-slate-900 opacity-0 shadow-sm transition-all duration-300 group-hover:opacity-100 group-focus-visible:opacity-100 dark:bg-zinc-900/85 dark:text-white"
                  aria-hidden="true"
                >
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    className="h-4 w-4"
                  >
                    <path d="M7 17 17 7M9 7h8v8" />
                  </svg>
                </span>
              </button>
            </li>
          ))}
        </ul>
      </div>

      {/* drawer */}
      <AnimatePresence>
        {isOpen && active && (
          <div className="fixed inset-0 z-50 flex justify-end" role="presentation">
            <motion.div
              key="backdrop"
              className="absolute inset-0 bg-slate-950/55 backdrop-blur-sm"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.25 }}
              onClick={close}
              aria-hidden="true"
            />

            <motion.div
              key="panel"
              ref={drawerRef}
              tabIndex={-1}
              role="dialog"
              aria-modal="true"
              aria-label={`${active.title} — details`}
              className="relative z-10 flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-2xl outline-none ring-1 ring-slate-900/10 sm:max-w-lg dark:bg-zinc-950 dark:ring-white/10"
              initial={{ x: "100%" }}
              animate={{ x: 0 }}
              exit={{ x: "100%" }}
              transition={{ type: "spring", stiffness: 320, damping: 36, mass: 0.9 }}
            >
              {/* header controls */}
              <div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white/85 px-5 py-3 backdrop-blur-md dark:border-white/10 dark:bg-zinc-950/85">
                <span className="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-zinc-500">
                  {openIndex! + 1} / {PHOTOS.length}
                </span>
                <div className="flex items-center gap-1.5">
                  <button
                    type="button"
                    onClick={goPrev}
                    aria-label="Previous photo"
                    className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-5 w-5"
                    >
                      <path d="m15 18-6-6 6-6" />
                    </svg>
                  </button>
                  <button
                    type="button"
                    onClick={goNext}
                    aria-label="Next photo"
                    className="flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-5 w-5"
                    >
                      <path d="m9 18 6-6-6-6" />
                    </svg>
                  </button>
                  <button
                    type="button"
                    onClick={close}
                    aria-label="Close details"
                    className="ml-1 flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={2}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-5 w-5"
                    >
                      <path d="M18 6 6 18M6 6l12 12" />
                    </svg>
                  </button>
                </div>
              </div>

              {/* content */}
              <AnimatePresence mode="wait">
                <motion.div
                  key={active.src}
                  initial={{ opacity: 0, y: 12 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -8 }}
                  transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
                  className="flex flex-1 flex-col"
                >
                  <div className="relative overflow-hidden bg-slate-100 dark:bg-zinc-900">
                    {/* eslint-disable-next-line @next/next/no-img-element */}
                    <img
                      src={active.src}
                      alt={active.alt}
                      loading="lazy"
                      draggable={false}
                      className="max-h-[52vh] w-full object-cover"
                    />
                    <span className="absolute left-4 top-4 inline-flex items-center rounded-full bg-slate-950/70 px-3 py-1 text-xs font-medium tracking-wide text-white uppercase backdrop-blur-sm ring-1 ring-white/15">
                      {active.category}
                    </span>
                  </div>

                  <div className="flex-1 px-6 py-6">
                    <h3 className="text-2xl font-semibold tracking-tight text-balance">
                      {active.title}
                    </h3>

                    <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-slate-500 dark:text-zinc-400">
                      <span className="inline-flex items-center gap-1.5">
                        <svg
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={1.8}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          className="h-4 w-4 text-indigo-500"
                          aria-hidden="true"
                        >
                          <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
                          <circle cx="12" cy="10" r="3" />
                        </svg>
                        {active.location}
                      </span>
                      <span aria-hidden="true" className="text-slate-300 dark:text-zinc-700">
                        •
                      </span>
                      <span>{active.date}</span>
                    </div>

                    <p className="mt-5 border-l-2 border-indigo-400 pl-4 text-base leading-relaxed text-slate-700 dark:border-indigo-500/60 dark:text-zinc-300">
                      {active.caption}
                    </p>

                    <dl className="mt-6 space-y-3 border-t border-slate-200 pt-6 dark:border-white/10">
                      <div className="flex items-start justify-between gap-4">
                        <dt className="text-sm font-medium text-slate-500 dark:text-zinc-500">
                          Alt description
                        </dt>
                        <dd className="max-w-[62%] text-right text-sm text-slate-700 dark:text-zinc-300">
                          {active.alt}
                        </dd>
                      </div>
                      <div className="flex items-start justify-between gap-4">
                        <dt className="text-sm font-medium text-slate-500 dark:text-zinc-500">
                          Capture
                        </dt>
                        <dd className="max-w-[62%] text-right font-mono text-xs text-slate-700 dark:text-zinc-300">
                          {active.camera}
                        </dd>
                      </div>
                    </dl>

                    <div className="mt-8 flex flex-wrap gap-3">
                      <a
                        href={active.src}
                        target="_blank"
                        rel="noopener"
                        className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950"
                      >
                        View full size
                        <svg
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={2}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          className="h-4 w-4"
                          aria-hidden="true"
                        >
                          <path d="M7 17 17 7M9 7h8v8" />
                        </svg>
                      </a>
                      <button
                        type="button"
                        onClick={close}
                        className="inline-flex items-center rounded-full border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-zinc-800"
                      >
                        Back to grid
                      </button>
                    </div>
                  </div>
                </motion.div>
              </AnimatePresence>
            </motion.div>
          </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.