Web InnoventixFreeCode

Thumb Preview Gallery

Original · free

A thumbnail rail where hovering a thumb shows a large preview panel.

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

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

type Shot = {
  src: string;
  title: string;
  category: string;
  caption: string;
  place: string;
  orientation: "portrait" | "landscape" | "square";
};

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g04.webp",
    title: "Concrete Cathedral",
    category: "Architecture",
    caption: "Brutalist stairwell photographed on a single overcast afternoon.",
    place: "Barbican, London",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Low Tide, High Sun",
    category: "Landscape",
    caption: "The estuary drains to a mirror an hour before the light goes gold.",
    place: "Holkham, Norfolk",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Studio No. 7",
    category: "Portrait",
    caption: "One softbox, one reflector, and a fifteen-minute window of quiet.",
    place: "Kreuzberg, Berlin",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g23.webp",
    title: "Market Reds",
    category: "Street",
    caption: "Chillies drying under canvas — shot from the hip while walking.",
    place: "Oaxaca, Mexico",
    orientation: "square",
  },
  {
    src: "/img/gallery/g27.webp",
    title: "Ridgeline",
    category: "Landscape",
    caption: "The last snowfield holds out against a warm May afternoon.",
    place: "Dolomites, Italy",
    orientation: "landscape",
  },
  {
    src: "/img/gallery/g30.webp",
    title: "Neon Alley",
    category: "Street",
    caption: "Rain turned the whole lane into a second, upside-down city.",
    place: "Shinjuku, Tokyo",
    orientation: "portrait",
  },
  {
    src: "/img/gallery/g33.webp",
    title: "Ceramic Study",
    category: "Still Life",
    caption: "A single glazed bowl, raked light, and a very patient assistant.",
    place: "Kyoto, Japan",
    orientation: "square",
  },
  {
    src: "/img/gallery/g36.webp",
    title: "Harbour Cranes",
    category: "Industrial",
    caption: "Shot at blue hour while the gantries were still and floodlit.",
    place: "Rotterdam, Netherlands",
    orientation: "landscape",
  },
];

export default function GalleryThumbPreview() {
  const [active, setActive] = useState(0);
  const uid = useId();
  const railRef = useRef<HTMLDivElement>(null);
  const current = SHOTS[active];

  const focusThumb = useCallback((index: number) => {
    const el = railRef.current?.querySelector<HTMLButtonElement>(
      `[data-thumb="${index}"]`,
    );
    el?.focus();
  }, []);

  const onRailKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      const last = SHOTS.length - 1;
      let next = active;
      switch (event.key) {
        case "ArrowRight":
        case "ArrowDown":
          next = active === last ? 0 : active + 1;
          break;
        case "ArrowLeft":
        case "ArrowUp":
          next = active === 0 ? last : active - 1;
          break;
        case "Home":
          next = 0;
          break;
        case "End":
          next = last;
          break;
        default:
          return;
      }
      event.preventDefault();
      setActive(next);
      focusThumb(next);
    },
    [active, focusThumb],
  );

  return (
    <section
      aria-labelledby={`${uid}-heading`}
      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-50"
    >
      <style>{`
        @keyframes gtp-kenburns {
          0% { transform: scale(1.02); }
          100% { transform: scale(1.09); }
        }
        @keyframes gtp-rise {
          0% { opacity: 0; transform: translateY(14px); }
          100% { opacity: 1; transform: translateY(0); }
        }
        @keyframes gtp-sheen {
          0% { transform: translateX(-120%) skewX(-12deg); }
          100% { transform: translateX(240%) skewX(-12deg); }
        }
        .gtp-kenburns { animation: gtp-kenburns 9s ease-out forwards; }
        .gtp-rise { animation: gtp-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
        @media (prefers-reduced-motion: reduce) {
          .gtp-kenburns,
          .gtp-rise { animation: none !important; }
        }
      `}</style>

      {/* ambient glow */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-40 right-[-10%] h-96 w-96 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute bottom-[-20%] left-[-10%] h-96 w-96 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="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-slate-300/70 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-zinc-700/70 dark:bg-zinc-900/60 dark:text-zinc-400">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
              Field Archive
            </span>
            <h2
              id={`${uid}-heading`}
              className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl"
            >
              Hover the rail,
              <span className="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent dark:from-indigo-400 dark:to-violet-400">
                {" "}
                see it full frame
              </span>
            </h2>
          </div>
          <p className="max-w-xs text-sm leading-relaxed text-slate-500 dark:text-zinc-400">
            Eight frames from the road. Point at any thumbnail to bring it up on
            the big screen — arrow keys work too.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_128px] lg:gap-8">
          {/* preview panel */}
          <figure className="relative order-2 overflow-hidden rounded-3xl border border-slate-200 bg-slate-100 shadow-[0_30px_60px_-25px_rgba(15,23,42,0.35)] lg:order-1 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_30px_60px_-25px_rgba(0,0,0,0.8)]">
            <div className="relative aspect-[4/3] w-full sm:aspect-[16/10]">
              <AnimatePresence initial={false} mode="popLayout">
                <motion.div
                  key={active}
                  initial={{ opacity: 0, scale: 1.04 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.99 }}
                  transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
                  className="absolute inset-0"
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={current.src}
                    alt={`${current.title} — ${current.caption}`}
                    loading="lazy"
                    draggable={false}
                    className="gtp-kenburns h-full w-full object-cover"
                  />
                </motion.div>
              </AnimatePresence>

              {/* caption gradient */}
              <div
                aria-hidden="true"
                className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent"
              />

              {/* metadata */}
              <figcaption
                key={`${active}-meta`}
                className="gtp-rise absolute inset-x-0 bottom-0 flex flex-col gap-2 p-6 sm:p-8"
              >
                <span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-white/90 backdrop-blur-sm ring-1 ring-inset ring-white/25">
                  {current.category}
                </span>
                <h3 className="text-xl font-semibold text-white sm:text-2xl">
                  {current.title}
                </h3>
                <p className="max-w-lg text-sm leading-relaxed text-white/75">
                  {current.caption}
                </p>
                <span className="mt-1 flex items-center gap-1.5 text-xs font-medium text-white/60">
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    className="h-3.5 w-3.5"
                    aria-hidden="true"
                  >
                    <path
                      d="M12 21s7-5.686 7-11a7 7 0 1 0-14 0c0 5.314 7 11 7 11Z"
                      stroke="currentColor"
                      strokeWidth="1.6"
                    />
                    <circle
                      cx="12"
                      cy="10"
                      r="2.4"
                      stroke="currentColor"
                      strokeWidth="1.6"
                    />
                  </svg>
                  {current.place}
                </span>
              </figcaption>

              {/* counter */}
              <div className="absolute right-5 top-5 rounded-full bg-black/40 px-3 py-1 text-xs font-medium tabular-nums text-white/90 backdrop-blur-sm ring-1 ring-inset ring-white/20">
                {String(active + 1).padStart(2, "0")}
                <span className="text-white/50"> / {String(SHOTS.length).padStart(2, "0")}</span>
              </div>
            </div>
          </figure>

          {/* thumbnail rail */}
          <div
            ref={railRef}
            role="listbox"
            aria-label="Photo thumbnails"
            aria-orientation="vertical"
            onKeyDown={onRailKeyDown}
            className="order-1 flex gap-3 overflow-x-auto pb-2 lg:order-2 lg:max-h-[560px] lg:flex-col lg:overflow-x-visible lg:overflow-y-auto lg:pb-0 lg:pr-1 [scrollbar-width:thin]"
          >
            {SHOTS.map((shot, index) => {
              const isActive = index === active;
              return (
                <button
                  key={shot.src}
                  type="button"
                  data-thumb={index}
                  role="option"
                  aria-selected={isActive}
                  aria-label={`${shot.title}, ${shot.category}, ${shot.place}`}
                  tabIndex={isActive ? 0 : -1}
                  onMouseEnter={() => setActive(index)}
                  onFocus={() => setActive(index)}
                  onClick={() => setActive(index)}
                  className={`group relative aspect-square w-24 shrink-0 overflow-hidden rounded-2xl outline-none ring-offset-2 ring-offset-slate-50 transition-all duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 lg:w-full dark:ring-offset-zinc-950 dark:focus-visible:ring-indigo-400 ${
                    isActive
                      ? "scale-[1.03] ring-2 ring-indigo-500 dark:ring-indigo-400"
                      : "opacity-60 ring-1 ring-slate-200 hover:opacity-100 dark:ring-zinc-800"
                  }`}
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={shot.src}
                    alt={shot.title}
                    loading="lazy"
                    draggable={false}
                    className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
                  />
                  <span
                    aria-hidden="true"
                    className={`pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 to-transparent transition-opacity duration-300 ${
                      isActive ? "opacity-100" : "opacity-0 group-hover:opacity-70"
                    }`}
                  />
                  {/* sheen sweep on hover */}
                  <span
                    aria-hidden="true"
                    className="pointer-events-none absolute inset-0 overflow-hidden"
                  >
                    <span className="absolute inset-y-0 -left-1/3 w-1/3 bg-white/25 opacity-0 [animation-fill-mode:forwards] group-hover:opacity-100 group-hover:[animation:gtp-sheen_0.8s_ease-out]" />
                  </span>
                  <span
                    className={`pointer-events-none absolute bottom-1.5 left-2 text-[0.6rem] font-semibold uppercase tracking-wide text-white drop-shadow transition-opacity duration-300 ${
                      isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
                    }`}
                  >
                    {shot.category}
                  </span>
                </button>
              );
            })}
          </div>
        </div>
      </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.