Innovations

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-scroll

Where 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).