Web InnoventixFreeCode

Featured Thumbs Gallery

Original · free

A large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

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

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

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

const SHOTS: Shot[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal light",
    category: "Landscape",
    alt: "Morning sun breaking over a quiet stretch of coastline",
  },
  {
    src: "/img/gallery/g08.webp",
    title: "Studio 04",
    category: "Portrait",
    alt: "Soft-lit studio portrait against a warm neutral backdrop",
  },
  {
    src: "/img/gallery/g14.webp",
    title: "Understory",
    category: "Nature",
    alt: "Dense green ferns caught in dappled forest light",
  },
  {
    src: "/img/gallery/g21.webp",
    title: "Concrete grid",
    category: "Architecture",
    alt: "Repeating geometry of a brutalist concrete facade",
  },
  {
    src: "/img/gallery/g27.webp",
    title: "Golden hour",
    category: "Landscape",
    alt: "Rolling hills washed in low golden evening light",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Still life 09",
    category: "Objects",
    alt: "Minimal still life arrangement on a textured surface",
  },
  {
    src: "/img/gallery/g19.webp",
    title: "City after rain",
    category: "Street",
    alt: "Reflections on a wet city street at dusk",
  },
  {
    src: "/img/gallery/g33.webp",
    title: "Quiet room",
    category: "Interior",
    alt: "Sunlit interior with clean lines and soft shadows",
  },
];

export default function GalleryFeaturedThumbs() {
  const [active, setActive] = useState(0);
  const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const select = useCallback((i: number) => {
    setActive(((i % SHOTS.length) + SHOTS.length) % SHOTS.length);
  }, []);

  const onKey = useCallback(
    (e: React.KeyboardEvent, i: number) => {
      let next = i;
      if (e.key === "ArrowRight" || e.key === "ArrowDown") next = i + 1;
      else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = i - 1;
      else if (e.key === "Home") next = 0;
      else if (e.key === "End") next = SHOTS.length - 1;
      else return;
      e.preventDefault();
      const clamped = ((next % SHOTS.length) + SHOTS.length) % SHOTS.length;
      select(clamped);
      thumbRefs.current[clamped]?.focus();
    },
    [select]
  );

  useEffect(() => {
    thumbRefs.current[active]?.scrollIntoView({
      behavior: "smooth",
      block: "nearest",
      inline: "center",
    });
  }, [active]);

  const current = SHOTS[active];

  return (
    <section
      className="relative w-full overflow-hidden bg-neutral-50 px-4 py-16 text-neutral-900 sm:px-6 sm:py-20 lg:px-8 dark:bg-neutral-950 dark:text-neutral-50"
      aria-label="Featured gallery"
    >
      <style>{`
        @keyframes gft-shimmer {
          0% { transform: translateX(-100%); }
          100% { transform: translateX(100%); }
        }
        .gft-shimmer::after {
          content: "";
          position: absolute;
          inset: 0;
          background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.14) 50%, transparent 70%);
          animation: gft-shimmer 2.6s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .gft-shimmer::after { animation: none; }
        }
      `}</style>

      {/* ambient glow */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-32 left-1/2 h-72 w-[42rem] -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-8 flex flex-col gap-3 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
              Selected work
            </p>
            <h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl lg:text-4xl">
              Featured gallery
            </h2>
          </div>
          <p className="max-w-sm text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
            A curated set of frames. Hover or tap a thumbnail to bring it into
            focus.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_7rem] lg:gap-8">
          {/* Featured stage */}
          <div className="relative">
            <div className="group relative aspect-[16/11] w-full overflow-hidden rounded-2xl bg-neutral-200 ring-1 ring-neutral-900/10 dark:bg-neutral-900 dark:ring-white/10">
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.div
                  key={current.src}
                  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.alt}
                    loading="lazy"
                    draggable={false}
                    className="h-full w-full object-cover"
                  />
                </motion.div>
              </AnimatePresence>

              <div
                aria-hidden="true"
                className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-black/5 to-transparent"
              />

              <AnimatePresence mode="wait">
                <motion.figcaption
                  key={current.title}
                  initial={{ opacity: 0, y: 12 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -8 }}
                  transition={{ duration: 0.4, ease: "easeOut" }}
                  className="absolute bottom-0 left-0 right-0 flex items-end justify-between gap-4 p-5 sm:p-6"
                >
                  <div>
                    <span className="inline-block rounded-full bg-white/15 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-wider text-white backdrop-blur">
                      {current.category}
                    </span>
                    <h3 className="mt-2 text-lg font-semibold text-white sm:text-xl">
                      {current.title}
                    </h3>
                  </div>
                  <span className="shrink-0 text-sm font-medium tabular-nums text-white/80">
                    {String(active + 1).padStart(2, "0")}
                    <span className="text-white/40"> / {String(SHOTS.length).padStart(2, "0")}</span>
                  </span>
                </motion.figcaption>
              </AnimatePresence>
            </div>

            {/* Prev / Next controls */}
            <div className="mt-4 flex items-center gap-2">
              <button
                type="button"
                onClick={() => select(active - 1)}
                aria-label="Previous image"
                className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-900/10 transition hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-white/10 dark:hover:bg-neutral-800"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
                  <path d="M15 18l-6-6 6-6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              <button
                type="button"
                onClick={() => select(active + 1)}
                aria-label="Next image"
                className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-900/10 transition hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-white/10 dark:hover:bg-neutral-800"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
                  <path d="M9 18l6-6-6-6" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              </button>
              <div className="ml-1 h-1.5 flex-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
                <motion.div
                  className="h-full rounded-full bg-indigo-500"
                  animate={{ width: `${((active + 1) / SHOTS.length) * 100}%` }}
                  transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
                />
              </div>
            </div>
          </div>

          {/* Thumbnail rail */}
          <div
            role="listbox"
            aria-label="Choose a photo"
            aria-orientation="vertical"
            className="flex gap-3 overflow-x-auto pb-2 lg:max-h-[30rem] lg:flex-col lg:overflow-y-auto lg:overflow-x-visible lg:pb-0 lg:pr-1 [scrollbar-width:thin]"
          >
            {SHOTS.map((shot, i) => {
              const selected = i === active;
              return (
                <button
                  key={shot.src}
                  ref={(el) => {
                    thumbRefs.current[i] = el;
                  }}
                  type="button"
                  role="option"
                  aria-selected={selected}
                  aria-label={`${shot.title}, ${shot.category}`}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => select(i)}
                  onMouseEnter={() => select(i)}
                  onFocus={() => select(i)}
                  onKeyDown={(e) => onKey(e, i)}
                  className={`relative aspect-square w-20 shrink-0 overflow-hidden rounded-xl outline-none transition duration-300 lg:w-full ${
                    selected
                      ? "ring-2 ring-indigo-500 ring-offset-2 ring-offset-neutral-50 dark:ring-offset-neutral-950"
                      : "opacity-70 ring-1 ring-neutral-900/10 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-indigo-400 dark:ring-white/10"
                  }`}
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={shot.src}
                    alt={shot.alt}
                    loading="lazy"
                    draggable={false}
                    className={`h-full w-full object-cover transition duration-500 ${
                      selected ? "scale-105" : "scale-100"
                    }`}
                  />
                  {!selected && (
                    <span
                      aria-hidden="true"
                      className="absolute inset-0 bg-neutral-950/10 dark:bg-neutral-950/30"
                    />
                  )}
                </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 →