Horizontal Scroll Gallery
Single row of snap-scrolling images. Click any image to open a built-in lightbox with keyboard navigation.
Preview
Source
tsx
"use client";
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
const IMAGES = [
{ src: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1600&q=80&auto=format&fit=crop", alt: "Mountain lake with evergreen trees" },
{ src: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1600&q=80&auto=format&fit=crop", alt: "Misty green hills" },
{ src: "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=1600&q=80&auto=format&fit=crop", alt: "Lakeside cabin" },
{ src: "https://images.unsplash.com/photo-1500964757637-c85e8a162699?w=1600&q=80&auto=format&fit=crop", alt: "Snowcapped peaks" },
{ src: "https://images.unsplash.com/photo-1499678329028-101435549a4e?w=1600&q=80&auto=format&fit=crop", alt: "Stream winding through rocks" },
{ src: "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?w=1600&q=80&auto=format&fit=crop", alt: "Pink sunrise over mountains" },
{ src: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1600&q=80&auto=format&fit=crop", alt: "Forest canopy from below" },
{ src: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=1600&q=80&auto=format&fit=crop", alt: "Foggy alpine lake at dusk" },
];
export default function GalleryHorizontalScroll() {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const isOpen = activeIndex !== null;
useEffect(() => {
if (!isOpen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActiveIndex(null);
if (e.key === "ArrowRight") {
setActiveIndex((i) => (i === null ? null : Math.min(IMAGES.length - 1, i + 1)));
}
if (e.key === "ArrowLeft") {
setActiveIndex((i) => (i === null ? null : Math.max(0, i - 1)));
}
};
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
};
}, [isOpen]);
return (
<section className="py-12 bg-background">
<div className="container mx-auto px-6 mb-8">
<h2 className="text-3xl md:text-4xl font-extrabold tracking-tight text-foreground">
Scroll gallery
</h2>
<p className="text-muted-foreground mt-1">
Swipe or scroll horizontally. Click any image to open the lightbox —
use ←/→ or ESC from there.
</p>
</div>
<div className="flex overflow-x-auto snap-x snap-mandatory gap-4 px-6 pb-6 container mx-auto">
{IMAGES.map((img, i) => (
<button
key={i}
type="button"
onClick={() => setActiveIndex(i)}
className="shrink-0 w-[85%] sm:w-[60%] md:w-[45%] lg:w-[32%] snap-start overflow-hidden rounded-xl bg-muted aspect-[4/3] group cursor-pointer"
>
<img
src={img.src}
alt={img.alt}
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</button>
))}
</div>
{isOpen && activeIndex !== null && (
<div
className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex items-center justify-center"
onClick={() => setActiveIndex(null)}
role="dialog"
aria-modal="true"
aria-label="Image lightbox"
>
<button
type="button"
aria-label="Close"
onClick={(e) => {
e.stopPropagation();
setActiveIndex(null);
}}
className="absolute top-4 right-4 inline-flex items-center justify-center w-10 h-10 rounded-full bg-background/80 border border-border text-foreground hover:bg-accent transition-colors"
>
<X className="w-5 h-5" />
</button>
{activeIndex > 0 && (
<button
type="button"
aria-label="Previous"
onClick={(e) => {
e.stopPropagation();
setActiveIndex((i) => (i === null ? null : Math.max(0, i - 1)));
}}
className="absolute left-4 inline-flex items-center justify-center w-10 h-10 rounded-full bg-background/80 border border-border text-foreground hover:bg-accent transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
)}
{activeIndex < IMAGES.length - 1 && (
<button
type="button"
aria-label="Next"
onClick={(e) => {
e.stopPropagation();
setActiveIndex((i) => (i === null ? null : Math.min(IMAGES.length - 1, i + 1)));
}}
className="absolute right-4 inline-flex items-center justify-center w-10 h-10 rounded-full bg-background/80 border border-border text-foreground hover:bg-accent transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
)}
<img
src={IMAGES[activeIndex].src}
alt={IMAGES[activeIndex].alt}
onClick={(e) => e.stopPropagation()}
className="max-w-5xl max-h-[85vh] w-auto h-auto object-contain rounded-lg shadow-2xl"
/>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 text-xs text-muted-foreground bg-background/80 border border-border rounded-full px-3 py-1">
{activeIndex + 1} / {IMAGES.length}
</div>
</div>
)}
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add horizontal-scrollWhere to use it
Use for showcase reels, portfolio highlights, or any set of 5–15 feature images.
In Astro (src/layouts/Layout.astro or a page):
import GalleryHorizontalScroll from '../components/innovations/galleries/horizontal-scroll';
The lightbox is built-in — no external library. ESC closes, ← / → navigate, click outside closes, clicking the image itself does nothing (prevents accidental close). Body scroll is locked while the lightbox is open.
Customize: replace the IMAGES array with your own {src, alt} entries. For production, use higher-resolution src URLs (w=1600 is already requested for the lightbox size).