Horizontal Snap Gallery
Original · freeA horizontal scroll-snap rail of images with arrows and a progress bar.
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"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
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 quoteSimilar components
Browse all →
Hover Caption Gallery
OriginalA responsive image grid where each tile subtly zooms and reveals a caption bar on hover.

Masonry Gallery
OriginalA Pinterest-style masonry gallery using CSS columns with varied-height images; tiles lift and brighten on hover.

Filterable Gallery
OriginalA filterable gallery with category tabs; clicking a tab animates the grid to show only that category.

Tilt Cards Gallery
OriginalA grid of image cards that tilt in 3D toward the cursor on hover, with a soft glare.

Lightbox Gallery
OriginalA thumbnail grid where clicking a thumb opens a full-screen lightbox with prev/next arrows and keyboard support.

Expanding Strip Gallery
OriginalA horizontal strip of image panels; hovering a panel expands it and shrinks the others to reveal its caption.

Marquee Gallery
OriginalTwo rows of images auto-scrolling in opposite directions, pausing on hover, with edge fade masks.

Hover Zoom Overlay Gallery
OriginalA grid where each image scales up under a dark gradient overlay revealing a title and a view button on hover.

Polaroid Stack Gallery
OriginalA scattered stack of slightly-rotated polaroid photos that straighten and lift on hover.
Featured Thumbs Gallery
OriginalA large featured image with a thumbnail row; selecting a thumbnail swaps the featured image with a crossfade.

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

Duotone Hover Gallery
OriginalA grid where images sit under a coloured duotone wash that clears to full colour on hover.

