Web InnoventixFreeCode

Vertical Marquee Gallery

Original · free

Vertical columns of images auto-scrolling up and down, pausing on hover.

byWeb InnoventixReact + Tailwind
verticalmarqueegalleryimages
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-vertical-marquee.json
gallery-vertical-marquee.tsx
"use client";

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

type Orientation = "portrait" | "landscape" | "square";

interface Photo {
  src: string;
  alt: string;
  title: string;
  category: string;
  orientation: Orientation;
}

const PHOTOS: Photo[] = [
  { src: "/img/gallery/g01.webp", alt: "Sunlit glass atrium with a suspended spiral staircase", title: "Atrium Light", category: "Architecture", orientation: "portrait" },
  { src: "/img/gallery/g02.webp", alt: "Cracked salt flats glowing orange under a low dawn sun", title: "Salt Flats at Dawn", category: "Landscape", orientation: "landscape" },
  { src: "/img/gallery/g03.webp", alt: "Unglazed ceramic bowls arranged on a linen cloth", title: "Ceramics, Unglazed", category: "Still Life", orientation: "square" },
  { src: "/img/gallery/g04.webp", alt: "Rain-slicked Parisian street reflecting café lights at night", title: "Rue de Seine", category: "Street", orientation: "portrait" },
  { src: "/img/gallery/g05.webp", alt: "Small ferry crossing a mirror-calm fjord between steep cliffs", title: "Fjord Crossing", category: "Landscape", orientation: "landscape" },
  { src: "/img/gallery/g06.webp", alt: "Studio portrait of a woman lit in deep blue evening tones", title: "Blue Hour Portrait", category: "Portrait", orientation: "square" },
  { src: "/img/gallery/g07.webp", alt: "Raw concrete stairwell curving upward toward a skylight", title: "Concrete Stair", category: "Architecture", orientation: "portrait" },
  { src: "/img/gallery/g08.webp", alt: "Rippled desert dunes casting long shadows at golden hour", title: "Dune Shadows", category: "Landscape", orientation: "landscape" },
  { src: "/img/gallery/g09.webp", alt: "Breakfast table set with coffee and fruit in soft morning light", title: "Morning Table", category: "Interiors", orientation: "square" },
  { src: "/img/gallery/g10.webp", alt: "Market vendor arranging produce beneath a striped awning", title: "Market Vendor", category: "Street", orientation: "portrait" },
  { src: "/img/gallery/g11.webp", alt: "Wooden pier vanishing into thick sea fog at first light", title: "Pier Fog", category: "Landscape", orientation: "landscape" },
  { src: "/img/gallery/g12.webp", alt: "Single tulip stem in a glass vase against a studio backdrop", title: "Studio Bloom", category: "Still Life", orientation: "square" },
];

const COLUMNS: { photos: Photo[]; animation: string }[] = [
  { photos: [PHOTOS[0], PHOTOS[3], PHOTOS[6], PHOTOS[9]], animation: "gvm-up-slow" },
  { photos: [PHOTOS[1], PHOTOS[4], PHOTOS[7], PHOTOS[10]], animation: "gvm-down-mid" },
  { photos: [PHOTOS[2], PHOTOS[5], PHOTOS[8], PHOTOS[11]], animation: "gvm-up-fast" },
];

const ASPECT: Record<Orientation, string> = {
  portrait: "aspect-[10/13]",
  landscape: "aspect-[13/9]",
  square: "aspect-square",
};

function PhotoCard({
  photo,
  index,
  onOpen,
  ariaHidden,
}: {
  photo: Photo;
  index: number;
  onOpen: (i: number) => void;
  ariaHidden: boolean;
}) {
  return (
    <button
      type="button"
      onClick={() => onOpen(index)}
      aria-hidden={ariaHidden}
      tabIndex={ariaHidden ? -1 : 0}
      aria-label={`View ${photo.title}, ${photo.category}`}
      className="group/card relative block w-full overflow-hidden rounded-2xl bg-slate-200 outline-none ring-indigo-500/70 transition-shadow duration-300 focus-visible:ring-2 dark:bg-zinc-800"
    >
      <div className={`${ASPECT[photo.orientation]} w-full overflow-hidden`}>
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={photo.src}
          alt={photo.alt}
          loading="lazy"
          draggable={false}
          className="h-full w-full object-cover transition-transform duration-700 ease-out will-change-transform group-hover/card:scale-[1.05]"
        />
      </div>
      <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/5 to-transparent opacity-70 transition-opacity duration-300 group-hover/card:opacity-100" />
      <div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 p-4 opacity-90 transition-all duration-300 group-hover/card:translate-y-0 group-hover/card:opacity-100">
        <span className="inline-flex items-center rounded-full bg-white/15 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-[0.14em] text-white/90 backdrop-blur-sm ring-1 ring-white/25">
          {photo.category}
        </span>
        <p className="mt-2 text-sm font-semibold leading-tight text-white drop-shadow-sm">
          {photo.title}
        </p>
      </div>
    </button>
  );
}

export default function GalleryVerticalMarquee() {
  const [active, setActive] = useState<number | null>(null);

  const open = useCallback((i: number) => setActive(i), []);
  const close = useCallback(() => setActive(null), []);
  const step = useCallback(
    (dir: number) =>
      setActive((prev) =>
        prev === null ? prev : (prev + dir + PHOTOS.length) % PHOTOS.length
      ),
    []
  );

  useEffect(() => {
    if (active === null) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      else if (e.key === "ArrowRight") step(1);
      else if (e.key === "ArrowLeft") step(-1);
    };
    window.addEventListener("keydown", onKey);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
    };
  }, [active, close, step]);

  const current = active === null ? null : PHOTOS[active];

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 sm:px-8 sm:py-24 lg:px-12 dark:bg-zinc-950">
      <style>{`
        @keyframes gvm-scroll-up { from { transform: translateY(0); } to { transform: translateY(-50%); } }
        @keyframes gvm-scroll-down { from { transform: translateY(-50%); } to { transform: translateY(0); } }
        .gvm-up-slow { animation: gvm-scroll-up 46s linear infinite; }
        .gvm-down-mid { animation: gvm-scroll-down 34s linear infinite; }
        .gvm-up-fast { animation: gvm-scroll-up 28s linear infinite; }
        .gvm-col:hover .gvm-track,
        .gvm-col:focus-within .gvm-track { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) {
          .gvm-track { animation: none !important; transform: none !important; }
        }
      `}</style>

      {/* ambient glow */}
      <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-indigo-300/30 blur-[120px] dark:bg-indigo-600/20"
      />

      <div className="relative mx-auto max-w-6xl">
        <header className="mx-auto max-w-2xl text-center">
          <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 uppercase tracking-[0.2em] text-indigo-600 shadow-sm backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Field Notebook
          </span>
          <h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl lg:text-5xl dark:text-white">
            Frames from the road
          </h2>
          <p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-zinc-400">
            A rolling reel of stills gathered across cities, coastlines, and quiet
            interiors. Hover a column to hold it still, then open any frame.
          </p>
        </header>

        <div className="relative mt-14">
          {/* top / bottom fades */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-slate-50 to-transparent dark:from-zinc-950"
          />
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-slate-50 to-transparent dark:from-zinc-950"
          />

          <ul
            className="grid h-[560px] grid-cols-2 gap-4 sm:h-[620px] sm:gap-5 lg:h-[720px] lg:grid-cols-3"
            aria-label="Photo gallery, auto-scrolling columns"
          >
            {COLUMNS.map((col, ci) => {
              const flatOffset = ci; // maps to PHOTOS interleave below
              return (
                <li
                  key={ci}
                  className={`gvm-col relative overflow-hidden ${
                    ci === 2 ? "hidden lg:block" : ""
                  }`}
                >
                  <div className={`gvm-track ${col.animation} flex flex-col gap-4 sm:gap-5`}>
                    {[...col.photos, ...col.photos].map((photo, pi) => {
                      const isClone = pi >= col.photos.length;
                      const baseIndex = pi % col.photos.length;
                      const globalIndex = baseIndex * 3 + flatOffset;
                      return (
                        <PhotoCard
                          key={`${ci}-${pi}`}
                          photo={photo}
                          index={globalIndex}
                          onOpen={open}
                          ariaHidden={isClone}
                        />
                      );
                    })}
                  </div>
                </li>
              );
            })}
          </ul>
        </div>
      </div>

      {/* Lightbox */}
      <AnimatePresence>
        {current && (
          <motion.div
            role="dialog"
            aria-modal="true"
            aria-label={`${current.title}, ${current.category}`}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            onClick={close}
            className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-md sm:p-8"
          >
            <button
              type="button"
              onClick={close}
              aria-label="Close viewer"
              className="absolute right-4 top-4 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/60 transition hover:bg-white/20 focus-visible:ring-2"
            >
              <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
                <path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
              </svg>
            </button>

            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                step(-1);
              }}
              aria-label="Previous photo"
              className="absolute left-3 top-1/2 z-10 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/60 transition hover:bg-white/20 focus-visible:ring-2 sm:left-6"
            >
              <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
                <path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
              </svg>
            </button>
            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                step(1);
              }}
              aria-label="Next photo"
              className="absolute right-3 top-1/2 z-10 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white outline-none ring-white/60 transition hover:bg-white/20 focus-visible:ring-2 sm:right-6"
            >
              <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
                <path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
              </svg>
            </button>

            <motion.figure
              key={active}
              initial={{ opacity: 0, scale: 0.96, y: 12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.97 }}
              transition={{ type: "spring", stiffness: 260, damping: 28 }}
              onClick={(e) => e.stopPropagation()}
              className="relative flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-white/10 dark:bg-zinc-900"
            >
              <div className="flex min-h-0 flex-1 items-center justify-center bg-slate-100 dark:bg-zinc-950">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={current.src}
                  alt={current.alt}
                  loading="lazy"
                  draggable={false}
                  className="max-h-[70vh] w-auto max-w-full object-contain"
                />
              </div>
              <figcaption className="flex items-center justify-between gap-4 border-t border-slate-200 px-6 py-4 dark:border-zinc-800">
                <div>
                  <p className="text-base font-semibold text-slate-900 dark:text-white">
                    {current.title}
                  </p>
                  <p className="mt-0.5 text-xs uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-300">
                    {current.category}
                  </p>
                </div>
                <span className="shrink-0 text-sm tabular-nums text-slate-400 dark:text-zinc-500">
                  {(active ?? 0) + 1} / {PHOTOS.length}
                </span>
              </figcaption>
            </motion.figure>
          </motion.div>
        )}
      </AnimatePresence>
    </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.