Web InnoventixFreeCode

Slideshow Fade Gallery

Original · free

An auto-advancing crossfade slideshow with a caption and progress dots.

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

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

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

const SLIDES: Slide[] = [
  {
    src: "/img/gallery/g04.webp",
    alt: "Sunrise breaking over a fog-filled alpine valley with layered ridgelines",
    title: "First Light on the Ridge",
    category: "Landscape",
    caption: "The fog burned off in under ten minutes — I shot forty frames and kept this one.",
    location: "Dolomites, Italy",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Neon-lit rain-slicked street in a dense night city with reflections on the pavement",
    title: "After the Rain",
    category: "Street",
    caption: "Every puddle turned into a second skyline once the signs flickered on.",
    location: "Shinjuku, Tokyo",
  },
  {
    src: "/img/gallery/g19.webp",
    alt: "Portrait of an elderly craftsman with weathered hands holding a hand-thrown clay bowl",
    title: "Fifty Years at the Wheel",
    category: "Portrait",
    caption: "He shaped the bowl in ninety seconds and told me it was still too fast.",
    location: "Bat Trang, Vietnam",
  },
  {
    src: "/img/gallery/g23.webp",
    alt: "Minimalist desert dune curve in warm golden light with a single ripple line",
    title: "One Clean Line",
    category: "Landscape",
    caption: "No footprints, no wind — just the dune holding its shape before noon.",
    location: "Erg Chebbi, Morocco",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Close-up of dew-covered fern fronds unfurling in soft green forest light",
    title: "Unfurling",
    category: "Macro",
    caption: "Shot at f/2.8 an hour after dawn, before the drops evaporated.",
    location: "Olympic Forest, USA",
  },
  {
    src: "/img/gallery/g30.webp",
    alt: "Aerial view of turquoise coastline meeting terracotta cliffs and a lone boat",
    title: "The Only Boat Out",
    category: "Aerial",
    caption: "Flew the drone to 120 meters and waited for the boat to hit the light.",
    location: "Amalfi Coast, Italy",
  },
  {
    src: "/img/gallery/g15.webp",
    alt: "Architectural detail of a spiral concrete staircase seen from directly below",
    title: "Concrete Spiral",
    category: "Architecture",
    caption: "Lay flat on the floor for this one — the symmetry only works from dead center.",
    location: "Vatican Museums, Rome",
  },
  {
    src: "/img/gallery/g27.webp",
    alt: "Silhouetted lone tree on a hilltop against a deep violet dusk sky with stars emerging",
    title: "Between Two Skies",
    category: "Landscape",
    caption: "The blue hour lasts about twelve minutes here — this was minute nine.",
    location: "Tuscany, Italy",
  },
  {
    src: "/img/gallery/g33.webp",
    alt: "Vibrant spice market stall stacked with pyramids of red, ochre and gold powders",
    title: "Pyramids of Color",
    category: "Travel",
    caption: "The vendor rebuilds these cones every morning by hand before the crowds.",
    location: "Marrakech, Morocco",
  },
];

const SLIDE_DURATION = 5200;

export default function GallerySlideshowFade() {
  const [active, setActive] = useState(0);
  const [paused, setPaused] = useState(false);
  const [progress, setProgress] = useState(0);
  const rafRef = useRef<number | null>(null);
  const startRef = useRef<number | null>(null);
  const liveRef = useRef<HTMLDivElement | null>(null);

  const count = SLIDES.length;

  const goTo = useCallback((index: number) => {
    setActive(((index % count) + count) % count);
    setProgress(0);
    startRef.current = null;
  }, [count]);

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

  useEffect(() => {
    if (paused) {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      startRef.current = null;
      return;
    }

    const tick = (now: number) => {
      if (startRef.current === null) startRef.current = now;
      const elapsed = now - startRef.current;
      const ratio = Math.min(elapsed / SLIDE_DURATION, 1);
      setProgress(ratio);

      if (ratio >= 1) {
        startRef.current = null;
        setProgress(0);
        setActive((a) => (a + 1) % count);
      } else {
        rafRef.current = requestAnimationFrame(tick);
      }
    };

    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, [active, paused, count]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.target !== e.currentTarget) return;
    if (e.key === "ArrowRight") {
      e.preventDefault();
      next();
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      prev();
    } else if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      setPaused((p) => !p);
    }
  };

  const current = SLIDES[active];

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-20 sm:px-6 lg:px-8 dark:bg-neutral-950">
      <style>{`
        @keyframes gsf-fade-in {
          from { opacity: 0; transform: scale(1.06); }
          to { opacity: 1; transform: scale(1); }
        }
        @keyframes gsf-rise {
          from { opacity: 0; transform: translateY(14px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .gsf-slide-active {
          animation: gsf-fade-in 1200ms cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        .gsf-meta-rise {
          animation: gsf-rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        @media (prefers-reduced-motion: reduce) {
          .gsf-slide-active,
          .gsf-meta-rise {
            animation: none !important;
          }
        }
      `}</style>

      {/* ambient glow */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-40 left-1/2 h-96 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="mx-auto mb-10 max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-indigo-600 uppercase backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/60 dark:text-indigo-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Field Notes
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
            A Year in Frames
          </h2>
          <p className="mt-3 text-base text-neutral-600 dark:text-neutral-400">
            Nine stops from the road — landscapes, faces and the odd concrete spiral.
          </p>
        </header>

        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="Photo slideshow"
          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-neutral-200 bg-neutral-900 shadow-2xl shadow-neutral-900/10 ring-1 ring-black/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-800 dark:shadow-black/40 dark:focus-visible:ring-offset-neutral-950"
        >
          <div className="relative aspect-[16/10] w-full sm:aspect-[16/9]">
            {SLIDES.map((slide, i) => {
              const isActive = i === active;
              return (
                <figure
                  key={slide.src}
                  aria-hidden={!isActive}
                  aria-roledescription="slide"
                  aria-label={`${i + 1} of ${count}: ${slide.title}`}
                  className={`absolute inset-0 transition-opacity duration-1000 ease-out ${
                    isActive ? "z-10 opacity-100" : "z-0 opacity-0"
                  }`}
                >
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={slide.src}
                    alt={slide.alt}
                    loading="lazy"
                    draggable={false}
                    className={`h-full w-full object-cover ${isActive ? "gsf-slide-active" : ""}`}
                  />
                  <div className="absolute inset-0 bg-gradient-to-t from-neutral-950/85 via-neutral-950/25 to-transparent" />
                </figure>
              );
            })}

            {/* caption */}
            <div className="absolute inset-x-0 bottom-0 z-20 p-6 sm:p-9">
              <div key={active} className="gsf-meta-rise max-w-xl">
                <div className="flex items-center gap-3 text-xs font-medium tracking-wide text-white/80 uppercase">
                  <span className="rounded-full bg-white/15 px-2.5 py-1 backdrop-blur-sm">
                    {current.category}
                  </span>
                  <span className="flex items-center gap-1.5 text-white/70">
                    <svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden>
                      <path
                        d="M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Z"
                        stroke="currentColor"
                        strokeWidth="1.6"
                      />
                      <circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="1.6" />
                    </svg>
                    {current.location}
                  </span>
                </div>
                <h3 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
                  {current.title}
                </h3>
                <p className="mt-2 max-w-md text-sm leading-relaxed text-white/75 sm:text-base">
                  {current.caption}
                </p>
              </div>
            </div>

            {/* prev / next */}
            <button
              type="button"
              onClick={prev}
              aria-label="Previous photo"
              className="absolute top-1/2 left-3 z-30 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white opacity-0 backdrop-blur-md transition hover:bg-white/25 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white group-hover:opacity-100 sm:left-5"
            >
              <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
                <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 photo"
              className="absolute top-1/2 right-3 z-30 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white opacity-0 backdrop-blur-md transition hover:bg-white/25 focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white group-hover:opacity-100 sm:right-5"
            >
              <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
                <path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>

            {/* pause / play */}
            <button
              type="button"
              onClick={() => setPaused((p) => !p)}
              aria-label={paused ? "Play slideshow" : "Pause slideshow"}
              aria-pressed={paused}
              className="absolute top-4 right-4 z-30 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur-md transition hover:bg-white/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-white sm:top-5 sm:right-5"
            >
              {paused ? (
                <svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden>
                  <path d="M8 5v14l11-7z" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden>
                  <path d="M7 5h3v14H7zM14 5h3v14h-3z" />
                </svg>
              )}
            </button>
          </div>

          {/* progress dots */}
          <div
            role="tablist"
            aria-label="Choose a photo"
            className="flex items-center justify-center gap-2.5 bg-neutral-950 px-4 py-4"
          >
            {SLIDES.map((slide, i) => {
              const isActive = i === active;
              return (
                <button
                  key={slide.src}
                  type="button"
                  role="tab"
                  aria-selected={isActive}
                  aria-label={`Go to ${slide.title}`}
                  onClick={() => goTo(i)}
                  className="group/dot relative h-2.5 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
                  style={{ width: isActive ? 44 : 10 }}
                >
                  <span className="absolute inset-0 rounded-full bg-white/20 transition group-hover/dot:bg-white/35" />
                  {isActive && (
                    <span
                      className="absolute inset-y-0 left-0 rounded-full bg-indigo-400"
                      style={{ width: `${Math.max(progress * 100, 8)}%` }}
                    />
                  )}
                </button>
              );
            })}
          </div>
        </div>

        {/* footer counter */}
        <div className="mx-auto mt-6 flex max-w-6xl items-center justify-between px-1 text-sm text-neutral-500 dark:text-neutral-500">
          <span className="tabular-nums">
            <span className="font-semibold text-neutral-800 dark:text-neutral-200">
              {String(active + 1).padStart(2, "0")}
            </span>{" "}
            / {String(count).padStart(2, "0")}
          </span>
          <span aria-live="polite" ref={liveRef} className="truncate pl-4 text-right">
            {current.title} — {current.location}
          </span>
        </div>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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.