Web InnoventixFreeCode

Marquee Gallery

Original · free

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

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

import { useState } from "react";

type Shot = {
  src: string;
  title: string;
  category: string;
  alt: string;
};

const ROW_TOP: Shot[] = [
  {
    src: "/img/gallery/g03.webp",
    title: "Coastal Light",
    category: "Landscape",
    alt: "Soft morning light spilling across a quiet coastline",
  },
  {
    src: "/img/gallery/g11.webp",
    title: "Studio 04",
    category: "Portrait",
    alt: "Studio portrait lit with a single warm key light",
  },
  {
    src: "/img/gallery/g18.webp",
    title: "Understory",
    category: "Nature",
    alt: "Green understory foliage catching dappled sunlight",
  },
  {
    src: "/img/gallery/g07.webp",
    title: "Concrete Grid",
    category: "Architecture",
    alt: "Repeating concrete facade shot from below",
  },
  {
    src: "/img/gallery/g22.webp",
    title: "Low Tide",
    category: "Landscape",
    alt: "Wet sand reflecting a pale sky at low tide",
  },
  {
    src: "/img/gallery/g14.webp",
    title: "Amber Hour",
    category: "Street",
    alt: "Amber evening light on a quiet city street",
  },
];

const ROW_BOTTOM: Shot[] = [
  {
    src: "/img/gallery/g27.webp",
    title: "Still Life 09",
    category: "Object",
    alt: "Minimal still life arrangement on a neutral surface",
  },
  {
    src: "/img/gallery/g05.webp",
    title: "Ridge Line",
    category: "Nature",
    alt: "Mountain ridge line fading into distant haze",
  },
  {
    src: "/img/gallery/g31.webp",
    title: "Glass & Steel",
    category: "Architecture",
    alt: "Glass and steel tower reflecting the afternoon sky",
  },
  {
    src: "/img/gallery/g19.webp",
    title: "Quiet Room",
    category: "Interior",
    alt: "Sunlit interior room with soft shadows on the wall",
  },
  {
    src: "/img/gallery/g24.webp",
    title: "Field Notes",
    category: "Nature",
    alt: "Tall grasses swaying in an open summer field",
  },
  {
    src: "/img/gallery/g09.webp",
    title: "Harbor Blue",
    category: "Landscape",
    alt: "Boats resting in a calm blue harbor at dawn",
  },
];

function MarqueeCard({
  shot,
  onOpen,
}: {
  shot: Shot;
  onOpen: (shot: Shot) => void;
}) {
  return (
    <button
      type="button"
      onClick={() => onOpen(shot)}
      aria-label={`View ${shot.title}, ${shot.category}`}
      className="group relative mx-3 block h-56 w-72 shrink-0 overflow-hidden rounded-2xl bg-slate-200 shadow-sm ring-1 ring-slate-900/5 outline-none transition-shadow duration-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:h-64 sm:w-80 dark:bg-slate-800 dark:ring-white/10 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
    >
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={shot.src}
        alt={shot.alt}
        loading="lazy"
        draggable={false}
        className="h-full w-full object-cover transition-transform duration-[900ms] ease-out will-change-transform group-hover:scale-105"
      />
      <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent opacity-90 transition-opacity duration-300 group-hover:opacity-100" />
      <div className="pointer-events-none absolute inset-x-0 bottom-0 translate-y-1 p-4 text-left transition-transform duration-300 group-hover:translate-y-0">
        <span className="inline-flex items-center rounded-full bg-white/15 px-2.5 py-0.5 text-[11px] font-medium tracking-wide text-white/90 uppercase backdrop-blur-sm ring-1 ring-white/20">
          {shot.category}
        </span>
        <h3 className="mt-2 text-base font-semibold text-white drop-shadow-sm">
          {shot.title}
        </h3>
      </div>
    </button>
  );
}

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

  const topLoop = [...ROW_TOP, ...ROW_TOP];
  const bottomLoop = [...ROW_BOTTOM, ...ROW_BOTTOM];

  return (
    <section className="relative w-full overflow-hidden bg-white px-0 py-20 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes gm_marquee_ltr {
          from { transform: translate3d(-50%, 0, 0); }
          to   { transform: translate3d(0, 0, 0); }
        }
        @keyframes gm_marquee_rtl {
          from { transform: translate3d(0, 0, 0); }
          to   { transform: translate3d(-50%, 0, 0); }
        }
        .gm-track {
          display: flex;
          width: max-content;
          will-change: transform;
        }
        .gm-row:hover .gm-track,
        .gm-row:focus-within .gm-track {
          animation-play-state: paused;
        }
        .gm-track-top {
          animation: gm_marquee_rtl 44s linear infinite;
        }
        .gm-track-bottom {
          animation: gm_marquee_ltr 52s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .gm-track-top,
          .gm-track-bottom {
            animation: none;
          }
          .gm-row {
            overflow-x: auto;
          }
        }
      `}</style>

      <div className="mx-auto mb-12 max-w-6xl px-6 sm:mb-16">
        <span className="inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold tracking-wide text-indigo-700 uppercase ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
          <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
          Selected Work
        </span>
        <h2 className="mt-4 max-w-2xl text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
          A gallery in motion
        </h2>
        <p className="mt-3 max-w-xl text-base text-slate-600 dark:text-slate-400">
          Two rows drifting in opposite directions. Hover to pause a row, then
          click any frame to open it full-size.
        </p>
      </div>

      {/* Edge fade masks + rows */}
      <div
        className="relative"
        style={{
          WebkitMaskImage:
            "linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
          maskImage:
            "linear-gradient(to right, transparent, black 6%, black 94%, transparent)",
        }}
      >
        <div className="gm-row group flex overflow-hidden py-2">
          <div className="gm-track gm-track-top" aria-hidden="false">
            {topLoop.map((shot, i) => (
              <MarqueeCard key={`top-${i}`} shot={shot} onOpen={setActive} />
            ))}
          </div>
        </div>

        <div className="gm-row group mt-4 flex overflow-hidden py-2">
          <div className="gm-track gm-track-bottom">
            {bottomLoop.map((shot, i) => (
              <MarqueeCard key={`bottom-${i}`} shot={shot} onOpen={setActive} />
            ))}
          </div>
        </div>
      </div>

      {/* Lightbox */}
      {active && (
        <div
          role="dialog"
          aria-modal="true"
          aria-label={`${active.title} enlarged`}
          className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4 backdrop-blur-sm"
          onClick={() => setActive(null)}
          onKeyDown={(e) => {
            if (e.key === "Escape") setActive(null);
          }}
        >
          <div
            className="relative max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-white/10 dark:bg-slate-900"
            onClick={(e) => e.stopPropagation()}
          >
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={active.src}
              alt={active.alt}
              loading="lazy"
              draggable={false}
              className="max-h-[70vh] w-full object-contain bg-slate-100 dark:bg-slate-950"
            />
            <div className="flex items-center justify-between gap-4 px-5 py-4">
              <div>
                <span className="text-[11px] font-medium tracking-wide text-indigo-600 uppercase dark:text-indigo-400">
                  {active.category}
                </span>
                <h3 className="mt-0.5 text-lg font-semibold text-slate-900 dark:text-white">
                  {active.title}
                </h3>
              </div>
              <button
                type="button"
                onClick={() => setActive(null)}
                aria-label="Close enlarged image"
                autoFocus
                className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-700 outline-none transition-colors hover:bg-slate-200 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
              >
                <svg
                  width="18"
                  height="18"
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  aria-hidden="true"
                >
                  <path d="M18 6 6 18M6 6l12 12" />
                </svg>
              </button>
            </div>
          </div>
        </div>
      )}
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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.

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.

Flip Cards Gallery

Flip Cards Gallery

Original

A grid of cards that flip in 3D on hover to reveal a caption and details on the back.