Web InnoventixFreeCode

Carousel Thumbs Gallery

Original · free

A large slide carousel synced with a clickable thumbnail strip.

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

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

type Slide = {
  src: string;
  alt: string;
  title: string;
  category: string;
  caption: string;
  location: string;
};

const SLIDES: Slide[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Terraced rice fields glowing gold under a low morning sun",
    title: "Terraces at First Light",
    category: "Landscape",
    caption: "Dawn spills across the stepped paddies before the valley wakes.",
    location: "Bali, Indonesia",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "A ceramicist's hands shaping wet clay on a spinning wheel",
    title: "The Potter's Hands",
    category: "Craft",
    caption: "Twenty years of muscle memory in a single pull of the wall.",
    location: "Bat Trang Village",
  },
  {
    src: "/img/gallery/g12.webp",
    alt: "Neon-lit alley reflecting in a rain-slicked street at night",
    title: "After the Rain",
    category: "Street",
    caption: "The city rewrites itself in reflections once the storm clears.",
    location: "Shinjuku, Tokyo",
  },
  {
    src: "/img/gallery/g18.webp",
    alt: "Snow-dusted pine forest fading into pale winter fog",
    title: "Silence, Measured",
    category: "Wild",
    caption: "Fog erases the horizon and the forest holds its breath.",
    location: "Hokkaidō Highlands",
  },
  {
    src: "/img/gallery/g21.webp",
    alt: "Close portrait of an elder with deep laugh lines and bright eyes",
    title: "Ninety Winters",
    category: "Portrait",
    caption: "Every line is a decade she is happy to have earned.",
    location: "Chefchaouen, Morocco",
  },
  {
    src: "/img/gallery/g25.webp",
    alt: "Aerial view of turquoise coastline meeting dark volcanic rock",
    title: "Where Two Blues Meet",
    category: "Aerial",
    caption: "Shot from 400 feet, the tide draws its own coastline.",
    location: "Amalfi Coast",
  },
  {
    src: "/img/gallery/g29.webp",
    alt: "Steam rising from a bowl of noodles in a warmly lit kitchen",
    title: "Bowl Number Forty",
    category: "Food",
    caption: "The fortieth bowl of the day, still made like the first.",
    location: "Fukuoka, Japan",
  },
  {
    src: "/img/gallery/g33.webp",
    alt: "Desert dunes carved into sharp ridges by evening wind",
    title: "Wind's Signature",
    category: "Landscape",
    caption: "The dunes redraw themselves every night; this held for one hour.",
    location: "Erg Chebbi, Sahara",
  },
];

const SLIDE_COUNT = SLIDES.length;

export default function GalleryCarouselThumbs() {
  const [active, setActive] = useState(0);
  const [direction, setDirection] = useState(1);
  const [paused, setPaused] = useState(false);
  const [isHydrated, setIsHydrated] = useState(false);

  const uid = useId().replace(/[:]/g, "");
  const anim = `gct_${uid}`;
  const thumbStripRef = useRef<HTMLDivElement | null>(null);
  const regionRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => setIsHydrated(true), []);

  const goTo = useCallback(
    (next: number) => {
      setActive((prev) => {
        const target = (next + SLIDE_COUNT) % SLIDE_COUNT;
        setDirection(target > prev || (prev === SLIDE_COUNT - 1 && target === 0) ? 1 : -1);
        return target;
      });
    },
    [],
  );

  const next = useCallback(() => {
    setDirection(1);
    setActive((prev) => (prev + 1) % SLIDE_COUNT);
  }, []);

  const prev = useCallback(() => {
    setDirection(-1);
    setActive((prev) => (prev - 1 + SLIDE_COUNT) % SLIDE_COUNT);
  }, []);

  // Autoplay
  useEffect(() => {
    if (paused || !isHydrated) return;
    const reduce =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce) return;
    const id = window.setInterval(next, 5200);
    return () => window.clearInterval(id);
  }, [paused, next, isHydrated]);

  // Keep the active thumbnail visible in the strip
  useEffect(() => {
    const strip = thumbStripRef.current;
    if (!strip) return;
    const el = strip.querySelector<HTMLElement>(`[data-thumb="${active}"]`);
    if (el) {
      el.scrollIntoView({
        behavior: "smooth",
        inline: "center",
        block: "nearest",
      });
    }
  }, [active]);

  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLDivElement>) => {
      if (e.key === "ArrowRight") {
        e.preventDefault();
        next();
      } else if (e.key === "ArrowLeft") {
        e.preventDefault();
        prev();
      } else if (e.key === "Home") {
        e.preventDefault();
        goTo(0);
      } else if (e.key === "End") {
        e.preventDefault();
        goTo(SLIDE_COUNT - 1);
      }
    },
    [next, prev, goTo],
  );

  const slide = SLIDES[active];

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-white via-slate-50 to-slate-100 px-4 py-16 sm:px-6 sm:py-20 lg:px-8 lg:py-28 dark:from-neutral-950 dark:via-neutral-950 dark:to-black">
      <style>{`
        @keyframes ${anim}_kenburns {
          0%   { transform: scale(1.04) translate3d(0,0,0); }
          100% { transform: scale(1.12) translate3d(-1.4%, -1.2%, 0); }
        }
        @keyframes ${anim}_shimmer {
          0%   { transform: translateX(-120%); }
          100% { transform: translateX(220%); }
        }
        @keyframes ${anim}_progress {
          0%   { transform: scaleX(0); }
          100% { transform: scaleX(1); }
        }
        .${anim}_ken   { animation: ${anim}_kenburns 8s ease-out forwards; }
        .${anim}_sheen { animation: ${anim}_shimmer 2.6s ease-in-out infinite; }
        .${anim}_bar   { animation: ${anim}_progress 5.2s linear forwards; transform-origin: left; }
        @media (prefers-reduced-motion: reduce) {
          .${anim}_ken,
          .${anim}_sheen,
          .${anim}_bar { animation: none !important; }
          .${anim}_ken { transform: scale(1.04); }
        }
      `}</style>

      <div className="relative mx-auto max-w-6xl">
        {/* Header */}
        <div className="mb-8 flex flex-col gap-4 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <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 uppercase tracking-[0.18em] text-indigo-700 dark:border-indigo-400/25 dark:bg-indigo-400/10 dark:text-indigo-300">
              <span className="relative flex h-1.5 w-1.5">
                <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-indigo-500 opacity-60" />
                <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-indigo-500" />
              </span>
              Field Notes
            </span>
            <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              A Year in Eight Frames
            </h2>
            <p className="mt-2 max-w-md text-sm text-slate-600 sm:text-base dark:text-neutral-400">
              Selected work from twelve months on the road, paired with the note
              scribbled the moment the shutter closed.
            </p>
          </div>

          <div className="flex items-center gap-3 text-sm text-slate-500 dark:text-neutral-500">
            <span className="tabular-nums font-medium text-slate-900 dark:text-white">
              {String(active + 1).padStart(2, "0")}
            </span>
            <span className="h-px w-8 bg-slate-300 dark:bg-neutral-700" />
            <span className="tabular-nums">{String(SLIDE_COUNT).padStart(2, "0")}</span>
          </div>
        </div>

        {/* Stage */}
        <div
          ref={regionRef}
          role="group"
          aria-roledescription="carousel"
          aria-label="Featured photograph carousel"
          tabIndex={0}
          onKeyDown={onKeyDown}
          onMouseEnter={() => setPaused(true)}
          onMouseLeave={() => setPaused(false)}
          onFocus={() => setPaused(true)}
          onBlur={() => setPaused(false)}
          className="group relative aspect-[4/3] w-full overflow-hidden rounded-2xl bg-slate-200 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:aspect-[16/10] lg:aspect-[16/9] dark:bg-neutral-900 dark:shadow-black/40 dark:ring-white/10 dark:focus-visible:ring-offset-neutral-950"
        >
          <AnimatePresence initial={false} custom={direction} mode="popLayout">
            <motion.div
              key={active}
              custom={direction}
              initial={{ opacity: 0, x: direction * 48, scale: 1.01 }}
              animate={{ opacity: 1, x: 0, scale: 1 }}
              exit={{ opacity: 0, x: direction * -48, scale: 1.01 }}
              transition={{
                x: { type: "spring", stiffness: 260, damping: 32 },
                opacity: { duration: 0.4, ease: "easeOut" },
                scale: { duration: 0.5, ease: "easeOut" },
              }}
              className="absolute inset-0"
              aria-roledescription="slide"
              aria-label={`${active + 1} of ${SLIDE_COUNT}: ${slide.title}`}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={slide.src}
                alt={slide.alt}
                loading="lazy"
                draggable={false}
                className={`${anim}_ken h-full w-full object-cover`}
              />
              <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/15 to-transparent" />
              <div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-black/25 to-transparent sm:from-black/20" />
            </motion.div>
          </AnimatePresence>

          {/* Caption overlay */}
          <div className="pointer-events-none absolute inset-x-0 bottom-0 p-5 sm:p-7 lg:p-9">
            <AnimatePresence mode="wait">
              <motion.div
                key={active}
                initial={{ opacity: 0, y: 14 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -8 }}
                transition={{ duration: 0.42, ease: "easeOut", delay: 0.08 }}
                className="max-w-xl"
              >
                <div className="mb-2 flex items-center gap-2.5">
                  <span className="rounded-full bg-white/15 px-2.5 py-0.5 text-[0.7rem] font-medium uppercase tracking-[0.14em] text-white backdrop-blur-sm">
                    {slide.category}
                  </span>
                  <span className="text-xs font-medium text-white/70">
                    {slide.location}
                  </span>
                </div>
                <h3 className="text-xl font-semibold tracking-tight text-white drop-shadow-sm sm:text-2xl lg:text-3xl">
                  {slide.title}
                </h3>
                <p className="mt-1.5 max-w-md text-sm text-white/80 sm:text-base">
                  {slide.caption}
                </p>
              </motion.div>
            </AnimatePresence>
          </div>

          {/* Prev / Next controls */}
          <button
            type="button"
            onClick={prev}
            aria-label="Previous photograph"
            className="absolute left-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-slate-800 shadow-lg backdrop-blur-md transition duration-200 hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black/20 sm:left-4 sm:h-11 sm:w-11 dark:bg-neutral-900/80 dark:text-white dark:hover:bg-neutral-900"
          >
            <svg
              viewBox="0 0 24 24"
              fill="none"
              className="h-5 w-5"
              aria-hidden="true"
            >
              <path
                d="M15 18l-6-6 6-6"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </button>
          <button
            type="button"
            onClick={next}
            aria-label="Next photograph"
            className="absolute right-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/85 text-slate-800 shadow-lg backdrop-blur-md transition duration-200 hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black/20 sm:right-4 sm:h-11 sm:w-11 dark:bg-neutral-900/80 dark:text-white dark:hover:bg-neutral-900"
          >
            <svg
              viewBox="0 0 24 24"
              fill="none"
              className="h-5 w-5"
              aria-hidden="true"
            >
              <path
                d="M9 6l6 6-6 6"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </button>

          {/* Autoplay progress bar */}
          <div className="absolute inset-x-0 top-0 z-10 h-0.5 bg-white/15">
            {!paused && isHydrated ? (
              <div
                key={active}
                className={`${anim}_bar h-full w-full bg-indigo-400`}
              />
            ) : null}
          </div>
        </div>

        {/* Thumbnail strip */}
        <div
          ref={thumbStripRef}
          role="tablist"
          aria-label="Choose a photograph"
          className="mt-4 flex gap-2.5 overflow-x-auto pb-2 sm:mt-5 sm:gap-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
        >
          {SLIDES.map((s, i) => {
            const isActive = i === active;
            return (
              <button
                key={s.src}
                data-thumb={i}
                type="button"
                role="tab"
                aria-selected={isActive}
                aria-label={`Show ${s.title}`}
                onClick={() => goTo(i)}
                className={`relative aspect-[4/3] h-16 flex-none overflow-hidden rounded-lg outline-none ring-offset-2 transition duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-white sm:h-20 dark:ring-offset-neutral-950 ${
                  isActive
                    ? "ring-2 ring-indigo-500 dark:ring-indigo-400"
                    : "opacity-60 ring-1 ring-slate-900/10 hover:opacity-100 dark:ring-white/10"
                }`}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={s.src}
                  alt={s.alt}
                  loading="lazy"
                  draggable={false}
                  className="h-full w-full object-cover"
                />
                {isActive ? (
                  <span className="pointer-events-none absolute inset-0 overflow-hidden">
                    <span
                      className={`${anim}_sheen absolute inset-y-0 -left-1/3 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/40 to-transparent`}
                    />
                  </span>
                ) : (
                  <span className="pointer-events-none absolute inset-0 bg-slate-900/20 dark:bg-black/30" />
                )}
              </button>
            );
          })}
        </div>

        {/* Dots (compact, keyboard-reachable summary) */}
        <div className="mt-5 flex items-center justify-center gap-1.5">
          {SLIDES.map((s, i) => (
            <button
              key={`dot-${s.src}`}
              type="button"
              aria-label={`Go to slide ${i + 1}`}
              aria-current={i === active}
              onClick={() => goTo(i)}
              className={`h-1.5 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950 ${
                i === active
                  ? "w-6 bg-indigo-500 dark:bg-indigo-400"
                  : "w-1.5 bg-slate-300 hover:bg-slate-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
              }`}
            />
          ))}
        </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.