Web InnoventixFreeCode

Horizontal Snap Gallery

Original · free

A horizontal scroll-snap rail of images with arrows and a progress bar.

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

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

type Slide = {
  src: string;
  alt: string;
  title: string;
  category: string;
  location: string;
  aspect: "portrait" | "landscape" | "square";
};

const SLIDES: Slide[] = [
  {
    src: "/img/gallery/g03.webp",
    alt: "Fog rolling between granite ridgelines at first light",
    title: "Granite at Dawn",
    category: "Alpine",
    location: "Dolomites, Italy",
    aspect: "portrait",
  },
  {
    src: "/img/gallery/g11.webp",
    alt: "Low sun stretching long shadows across a dune field",
    title: "The Long Shadow",
    category: "Desert",
    location: "Erg Chebbi, Morocco",
    aspect: "landscape",
  },
  {
    src: "/img/gallery/g07.webp",
    alt: "Weathered fishing boats moored in still harbour water",
    title: "Moored at Slack Tide",
    category: "Coast",
    location: "Nazaré, Portugal",
    aspect: "square",
  },
  {
    src: "/img/gallery/g19.webp",
    alt: "Neon reflections pooling on a rain-soaked side street",
    title: "After the Rain",
    category: "Street",
    location: "Osaka, Japan",
    aspect: "portrait",
  },
  {
    src: "/img/gallery/g24.webp",
    alt: "A single road cutting through endless amber grassland",
    title: "Nowhere Road",
    category: "Journey",
    location: "Great Karoo, South Africa",
    aspect: "landscape",
  },
  {
    src: "/img/gallery/g15.webp",
    alt: "Frost-covered pine branches against a pale winter sky",
    title: "Silver Thaw",
    category: "Forest",
    location: "Lapland, Finland",
    aspect: "square",
  },
  {
    src: "/img/gallery/g28.webp",
    alt: "Terraced rice paddies glowing green after monsoon rain",
    title: "Stepped in Green",
    category: "Highland",
    location: "Bali, Indonesia",
    aspect: "portrait",
  },
  {
    src: "/img/gallery/g05.webp",
    alt: "Waves breaking over dark volcanic rock under grey cloud",
    title: "Basalt & Break",
    category: "Coast",
    location: "Vík, Iceland",
    aspect: "landscape",
  },
  {
    src: "/img/gallery/g31.webp",
    alt: "Warm lamplight spilling from a narrow stone alleyway at dusk",
    title: "Lamplight Hour",
    category: "Street",
    location: "Chefchaouen, Morocco",
    aspect: "square",
  },
  {
    src: "/img/gallery/g09.webp",
    alt: "Star trails arcing over a silhouetted mountain summit",
    title: "Turning Sky",
    category: "Night",
    location: "Atacama, Chile",
    aspect: "portrait",
  },
];

const ASPECT_CLASS: Record<Slide["aspect"], string> = {
  portrait: "aspect-[4/5] sm:w-[320px] md:w-[360px]",
  landscape: "aspect-[13/9] sm:w-[440px] md:w-[520px]",
  square: "aspect-square sm:w-[360px] md:w-[400px]",
};

export default function GalleryHorizontalSnap() {
  const railRef = useRef<HTMLUListElement>(null);
  const sectionRef = useRef<HTMLElement>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });

  const [progress, setProgress] = useState(0);
  const [atStart, setAtStart] = useState(true);
  const [atEnd, setAtEnd] = useState(false);
  const [active, setActive] = useState(0);

  const recompute = useCallback(() => {
    const rail = railRef.current;
    if (!rail) return;
    const max = rail.scrollWidth - rail.clientWidth;
    const left = rail.scrollLeft;
    setProgress(max > 0 ? Math.min(1, Math.max(0, left / max)) : 0);
    setAtStart(left <= 2);
    setAtEnd(left >= max - 2);

    const items = Array.from(rail.querySelectorAll<HTMLElement>("[data-slide]"));
    const center = left + rail.clientWidth / 2;
    let nearest = 0;
    let best = Infinity;
    items.forEach((el, i) => {
      const mid = el.offsetLeft + el.offsetWidth / 2;
      const d = Math.abs(mid - center);
      if (d < best) {
        best = d;
        nearest = i;
      }
    });
    setActive(nearest);
  }, []);

  useEffect(() => {
    const rail = railRef.current;
    if (!rail) return;
    recompute();
    rail.addEventListener("scroll", recompute, { passive: true });
    window.addEventListener("resize", recompute);
    return () => {
      rail.removeEventListener("scroll", recompute);
      window.removeEventListener("resize", recompute);
    };
  }, [recompute]);

  const scrollByCard = useCallback((dir: 1 | -1) => {
    const rail = railRef.current;
    if (!rail) return;
    const first = rail.querySelector<HTMLElement>("[data-slide]");
    const step = first ? first.offsetWidth + 24 : rail.clientWidth * 0.8;
    rail.scrollBy({ left: dir * step, behavior: "smooth" });
  }, []);

  const scrollToIndex = useCallback((i: number) => {
    const rail = railRef.current;
    if (!rail) return;
    const items = rail.querySelectorAll<HTMLElement>("[data-slide]");
    const el = items[i];
    if (!el) return;
    const target = el.offsetLeft - (rail.clientWidth - el.offsetWidth) / 2;
    rail.scrollTo({ left: target, behavior: "smooth" });
  }, []);

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-gradient-to-b from-white via-slate-50 to-white py-20 sm:py-28 dark:from-neutral-950 dark:via-neutral-900 dark:to-neutral-950"
      aria-labelledby="ghs-heading"
    >
      <style>{`
        @keyframes ghs-rise {
          from { opacity: 0; transform: translateY(28px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes ghs-sheen {
          0% { transform: translateX(-120%) skewX(-12deg); }
          100% { transform: translateX(320%) skewX(-12deg); }
        }
        .ghs-rail {
          scrollbar-width: none;
          -ms-overflow-style: none;
        }
        .ghs-rail::-webkit-scrollbar { display: none; }
        @media (prefers-reduced-motion: reduce) {
          .ghs-sheen { animation: none !important; }
          .ghs-anim {
            animation: none !important;
            opacity: 1 !important;
            transform: none !important;
          }
        }
      `}</style>

      {/* ambient wash */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-gradient-to-r from-indigo-300/30 via-violet-300/20 to-emerald-300/20 blur-3xl dark:from-indigo-500/20 dark:via-violet-500/15 dark:to-emerald-500/10"
      />

      <div className="relative mx-auto w-full max-w-7xl px-5 sm:px-8">
        {/* header */}
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div
            className="ghs-anim max-w-2xl"
            style={{
              animation: inView ? "ghs-rise 0.7s cubic-bezier(0.22,1,0.36,1) both" : undefined,
            }}
          >
            <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-neutral-800 dark:bg-neutral-900/70 dark:text-neutral-300">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Field Journal — 2026
            </span>
            <h2
              id="ghs-heading"
              className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl md:text-5xl dark:text-white"
            >
              A rail of quiet places
            </h2>
            <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-neutral-400">
              Ten frames shot on the road this year. Drag, swipe, or use the arrows — each card
              snaps into place.
            </p>
          </div>

          {/* arrows */}
          <div className="flex items-center gap-3">
            <button
              type="button"
              onClick={() => scrollByCard(-1)}
              disabled={atStart}
              aria-label="Previous images"
              className="group grid h-12 w-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-35 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus-visible:ring-offset-neutral-950"
            >
              <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
                <path
                  d="M15 6l-6 6 6 6"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
            <button
              type="button"
              onClick={() => scrollByCard(1)}
              disabled={atEnd}
              aria-label="Next images"
              className="group grid h-12 w-12 place-items-center rounded-full border border-slate-900 bg-slate-900 text-white shadow-sm transition hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-35 dark:border-white dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200 dark:focus-visible:ring-offset-neutral-950"
            >
              <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="1.8"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
          </div>
        </div>

        {/* rail */}
        <div className="relative mt-10">
          {/* edge fades */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-y-0 left-0 z-10 w-10 bg-gradient-to-r from-white to-transparent dark:from-neutral-950"
          />
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-y-0 right-0 z-10 w-10 bg-gradient-to-l from-white to-transparent dark:from-neutral-950"
          />

          <ul
            ref={railRef}
            className="ghs-rail flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth px-1 pb-6"
            aria-label="Photo gallery, horizontally scrollable"
          >
            {SLIDES.map((slide, i) => (
              <li
                key={slide.src}
                data-slide
                className={`ghs-anim group relative shrink-0 snap-center ${ASPECT_CLASS[slide.aspect]} w-[78vw] max-w-[86vw]`}
                style={{
                  animation: inView
                    ? `ghs-rise 0.6s cubic-bezier(0.22,1,0.36,1) ${i * 0.06}s both`
                    : undefined,
                }}
              >
                <figure className="relative h-full w-full overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 shadow-[0_12px_40px_-18px_rgba(15,23,42,0.45)] transition-transform duration-500 will-change-transform group-hover:-translate-y-1 dark:border-neutral-800 dark:bg-neutral-900">
                  {/* 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 transition-transform duration-700 ease-out group-hover:scale-[1.05]"
                  />

                  {/* sheen on hover */}
                  <span
                    aria-hidden="true"
                    className="pointer-events-none absolute inset-0 overflow-hidden"
                  >
                    <span className="ghs-sheen absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent opacity-0 [animation:ghs-sheen_1.1s_ease-out] group-hover:opacity-100" />
                  </span>

                  <div
                    aria-hidden="true"
                    className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-slate-950/85 via-slate-950/25 to-transparent"
                  />

                  <div className="absolute left-4 top-4">
                    <span className="inline-flex items-center rounded-full bg-white/85 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-slate-800 backdrop-blur dark:bg-neutral-950/70 dark:text-neutral-100">
                      {slide.category}
                    </span>
                  </div>

                  <figcaption className="absolute inset-x-0 bottom-0 p-4 sm:p-5">
                    <h3 className="text-lg font-semibold text-white sm:text-xl">{slide.title}</h3>
                    <p className="mt-0.5 flex items-center gap-1.5 text-sm text-slate-200/90">
                      <svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
                        <path
                          d="M12 21s7-6.3 7-11a7 7 0 10-14 0c0 4.7 7 11 7 11z"
                          stroke="currentColor"
                          strokeWidth="1.6"
                        />
                        <circle cx="12" cy="10" r="2.4" stroke="currentColor" strokeWidth="1.6" />
                      </svg>
                      {slide.location}
                    </p>
                  </figcaption>
                </figure>
              </li>
            ))}
          </ul>
        </div>

        {/* progress + dots */}
        <div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
          <div className="flex-1">
            <div
              className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-neutral-800"
              role="progressbar"
              aria-label="Gallery scroll progress"
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={Math.round(progress * 100)}
            >
              <motion.span
                className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-indigo-500 via-violet-500 to-emerald-500"
                style={{ width: `${Math.max(6, progress * 100)}%` }}
                aria-hidden="true"
              />
            </div>
          </div>

          <div className="flex items-center gap-2" role="tablist" aria-label="Jump to image">
            {SLIDES.map((slide, i) => (
              <button
                key={slide.src}
                type="button"
                role="tab"
                aria-selected={active === i}
                aria-label={`Go to ${slide.title}`}
                onClick={() => scrollToIndex(i)}
                className="group grid place-items-center p-1 focus-visible:outline-none"
              >
                <span
                  className={`block h-2 rounded-full transition-all duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                    active === i
                      ? "w-6 bg-slate-900 dark:bg-white"
                      : "w-2 bg-slate-300 group-hover:bg-slate-400 dark:bg-neutral-700 dark:group-hover:bg-neutral-500"
                  }`}
                />
              </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.