Web InnoventixFreeCode

Carousel Gallery

Original · free

An image carousel with arrows and dots, smooth slide transitions and autoplay that pauses on hover.

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

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

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

const SLIDES: Slide[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal Light",
    category: "Landscape",
    caption: "First sun breaking over the northern headland.",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Studio 04",
    category: "Portrait",
    caption: "Soft key light, one reflector, no retouching.",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Quiet Verticals",
    category: "Architecture",
    caption: "Concrete and glass in the late afternoon.",
  },
  {
    src: "/img/gallery/g22.webp",
    title: "Understory",
    category: "Nature",
    caption: "Fern detail from the valley floor, shot at f/2.8.",
  },
  {
    src: "/img/gallery/g07.webp",
    title: "Market Hour",
    category: "Street",
    caption: "Movement and stillness on a Tuesday morning.",
  },
  {
    src: "/img/gallery/g29.webp",
    title: "Salt & Slate",
    category: "Still Life",
    caption: "Found textures arranged on a windowsill.",
  },
  {
    src: "/img/gallery/g14.webp",
    title: "High Country",
    category: "Landscape",
    caption: "Ridge line fading into weather at dusk.",
  },
];

const AUTOPLAY_MS = 5000;

export default function GalleryCarousel() {
  const [index, setIndex] = useState(0);
  const [direction, setDirection] = useState(1);
  const [paused, setPaused] = useState(false);
  const regionRef = useRef<HTMLDivElement>(null);
  const count = SLIDES.length;

  const goTo = useCallback(
    (next: number, dir: number) => {
      setDirection(dir);
      setIndex(((next % count) + count) % count);
    },
    [count],
  );

  const prev = useCallback(() => goTo(index - 1, -1), [goTo, index]);
  const next = useCallback(() => goTo(index + 1, 1), [goTo, index]);

  useEffect(() => {
    if (paused) return;
    const id = window.setTimeout(() => goTo(index + 1, 1), AUTOPLAY_MS);
    return () => window.clearTimeout(id);
  }, [index, paused, goTo]);

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowLeft") {
      event.preventDefault();
      prev();
    } else if (event.key === "ArrowRight") {
      event.preventDefault();
      next();
    }
  };

  const active = SLIDES[index];

  const variants = {
    enter: (dir: number) => ({
      x: dir > 0 ? "100%" : "-100%",
      opacity: 0,
      scale: 1.04,
    }),
    center: { x: "0%", opacity: 1, scale: 1 },
    exit: (dir: number) => ({
      x: dir > 0 ? "-100%" : "100%",
      opacity: 0,
      scale: 1.04,
    }),
  };

  return (
    <section className="relative w-full bg-gradient-to-b from-slate-50 to-white px-4 py-20 sm:px-6 lg:py-28 dark:from-neutral-950 dark:to-black">
      <style>{`
        @keyframes gcar-progress {
          from { transform: scaleX(0); }
          to { transform: scaleX(1); }
        }
        @media (prefers-reduced-motion: reduce) {
          .gcar-progress-bar { animation: none !important; transform: scaleX(1); }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div 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-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              Field Notebook
            </h2>
          </div>
          <p className="max-w-sm text-sm leading-relaxed text-slate-500 dark:text-neutral-400">
            A rotating set of frames from the archive. Hover to pause, use the
            arrows, dots, or your keyboard to move.
          </p>
        </div>

        <div
          ref={regionRef}
          role="group"
          aria-roledescription="carousel"
          aria-label="Photo gallery carousel"
          tabIndex={0}
          onKeyDown={onKeyDown}
          onMouseEnter={() => setPaused(true)}
          onMouseLeave={() => setPaused(false)}
          onFocus={() => setPaused(true)}
          onBlur={() => setPaused(false)}
          className="group relative overflow-hidden rounded-3xl border border-slate-200/80 bg-slate-100 shadow-[0_20px_60px_-20px_rgba(15,23,42,0.35)] outline-none ring-indigo-500/60 focus-visible:ring-2 dark:border-white/10 dark:bg-neutral-900 dark:shadow-[0_20px_60px_-20px_rgba(0,0,0,0.8)]"
        >
          <div className="relative aspect-[16/10] w-full sm:aspect-[16/9]">
            <AnimatePresence initial={false} custom={direction}>
              <motion.div
                key={index}
                custom={direction}
                variants={variants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{
                  x: { type: "spring", stiffness: 260, damping: 32 },
                  opacity: { duration: 0.35 },
                  scale: { duration: 0.5, ease: "easeOut" },
                }}
                className="absolute inset-0"
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={active.src}
                  alt={`${active.title} — ${active.caption}`}
                  loading="lazy"
                  draggable={false}
                  className="h-full w-full select-none object-cover"
                />
                <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-transparent" />
              </motion.div>
            </AnimatePresence>

            <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 p-5 sm:p-7">
              <AnimatePresence mode="wait">
                <motion.div
                  key={active.title}
                  initial={{ opacity: 0, y: 12 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -8 }}
                  transition={{ duration: 0.4, ease: "easeOut" }}
                >
                  <span className="inline-flex items-center rounded-full bg-white/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-white/90 backdrop-blur-md ring-1 ring-white/20">
                    {active.category}
                  </span>
                  <h3 className="mt-3 text-2xl font-semibold text-white drop-shadow-sm sm:text-3xl">
                    {active.title}
                  </h3>
                  <p className="mt-1 max-w-md text-sm text-white/80">
                    {active.caption}
                  </p>
                </motion.div>
              </AnimatePresence>
            </div>

            <button
              type="button"
              onClick={prev}
              aria-label="Previous image"
              className="absolute left-3 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/80 p-2.5 text-slate-800 shadow-lg backdrop-blur-md transition hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-900/70 dark:text-white dark:hover:bg-neutral-900"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden="true"
              >
                <path d="m15 18-6-6 6-6" />
              </svg>
            </button>
            <button
              type="button"
              onClick={next}
              aria-label="Next image"
              className="absolute right-3 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/80 p-2.5 text-slate-800 shadow-lg backdrop-blur-md transition hover:scale-105 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-900/70 dark:text-white dark:hover:bg-neutral-900"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden="true"
              >
                <path d="m9 18 6-6-6-6" />
              </svg>
            </button>

            <div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
              {!paused && (
                <div
                  key={index}
                  className="gcar-progress-bar h-full origin-left bg-indigo-400"
                  style={{
                    animation: `gcar-progress ${AUTOPLAY_MS}ms linear forwards`,
                  }}
                />
              )}
            </div>
          </div>

          <div className="flex items-center justify-between gap-4 border-t border-slate-200/80 bg-white/70 px-5 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-neutral-900/60">
            <div
              className="flex items-center gap-2"
              role="tablist"
              aria-label="Choose image"
            >
              {SLIDES.map((slide, i) => {
                const activeDot = i === index;
                return (
                  <button
                    key={slide.src}
                    type="button"
                    role="tab"
                    aria-selected={activeDot}
                    aria-label={`Go to ${slide.title}`}
                    onClick={() => goTo(i, i > index ? 1 : -1)}
                    className={`h-2.5 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                      activeDot
                        ? "w-7 bg-indigo-600 dark:bg-indigo-400"
                        : "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
                    }`}
                  />
                );
              })}
            </div>

            <span
              className="text-sm font-medium tabular-nums text-slate-500 dark:text-neutral-400"
              aria-live="polite"
            >
              {String(index + 1).padStart(2, "0")}
              <span className="mx-1 text-slate-300 dark:text-neutral-600">
                /
              </span>
              {String(count).padStart(2, "0")}
            </span>
          </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.

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.