Image Marquee
Original · freeimage marquee
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/mqx-image.json"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useReducedMotion } from "motion/react";
type Photo = {
id: string;
src: string;
alt: string;
title: string;
place: string;
year: string;
};
type Entry = { photo: Photo; index: number };
type StyleVars = CSSProperties & Record<string, string | number>;
type SpeedId = "slow" | "normal" | "fast";
const PHOTOS: Photo[] = [
{ id: "g01", src: "/img/gallery/g01.webp", alt: "Low tide exposing black volcanic sand at first light, Jokulsarlon, Iceland", title: "Low tide at dawn", place: "Jokulsarlon, Iceland", year: "2021" },
{ id: "g02", src: "/img/gallery/g02.webp", alt: "Silk lanterns strung above a market lane before the stalls open, Hoi An, Vietnam", title: "Lanterns before opening", place: "Hoi An, Vietnam", year: "2019" },
{ id: "g03", src: "/img/gallery/g03.webp", alt: "A yellow funicular tram climbing a fog-filled hillside street, Lisbon, Portugal", title: "Tram into the fog", place: "Lisbon, Portugal", year: "2022" },
{ id: "g04", src: "/img/gallery/g04.webp", alt: "Flooded salt flat mirroring a pale sky at the horizon, Uyuni, Bolivia", title: "The mirrored flats", place: "Uyuni, Bolivia", year: "2018" },
{ id: "g05", src: "/img/gallery/g05.webp", alt: "Red sandstone switchbacks carved into a desert canyon wall, Moab, Utah", title: "Red rock switchbacks", place: "Moab, Utah", year: "2020" },
{ id: "g06", src: "/img/gallery/g06.webp", alt: "Laundry lines strung between old buildings over a narrow alley, Naples, Italy", title: "Rooftop laundry lines", place: "Naples, Italy", year: "2019" },
{ id: "g07", src: "/img/gallery/g07.webp", alt: "First snow dusting a hairpin mountain pass at daybreak, Furka, Switzerland", title: "First snow on the pass", place: "Furka, Switzerland", year: "2023" },
{ id: "g08", src: "/img/gallery/g08.webp", alt: "Blue fishing boats resting on still water at slack tide, Essaouira, Morocco", title: "Boats at slack water", place: "Essaouira, Morocco", year: "2017" },
{ id: "g09", src: "/img/gallery/g09.webp", alt: "Terraced rice paddies catching low golden light on a hillside, Bali, Indonesia", title: "Terraces at golden hour", place: "Bali, Indonesia", year: "2018" },
{ id: "g10", src: "/img/gallery/g10.webp", alt: "An empty subway platform lit by fluorescent light near the last train, Seoul, South Korea", title: "Last train home", place: "Seoul, South Korea", year: "2022" },
{ id: "g11", src: "/img/gallery/g11.webp", alt: "Wind-sculpted dunes with fresh ripples after a sandstorm, Merzouga, Sahara", title: "After the sandstorm", place: "Merzouga, Sahara", year: "2019" },
{ id: "g12", src: "/img/gallery/g12.webp", alt: "Silhouetted harbor cranes standing against a deep blue-hour sky, Rotterdam, Netherlands", title: "Cranes at blue hour", place: "Rotterdam, Netherlands", year: "2021" },
{ id: "g13", src: "/img/gallery/g13.webp", alt: "Stone steps climbing to a cliffside monastery above the valley, Meteora, Greece", title: "Steps to the monastery", place: "Meteora, Greece", year: "2016" },
{ id: "g14", src: "/img/gallery/g14.webp", alt: "Wildflowers crowding a weathered fence line under rolling hills, Palouse, Washington", title: "Along the fence line", place: "Palouse, Washington", year: "2020" },
{ id: "g15", src: "/img/gallery/g15.webp", alt: "Rain falling under a medieval arcade in the old town center, Bologna, Italy", title: "Arcade in the rain", place: "Bologna, Italy", year: "2019" },
{ id: "g16", src: "/img/gallery/g16.webp", alt: "A ferry cutting a bright wake across calm water at sunrise, Bergen, Norway", title: "Ferry wake at sunrise", place: "Bergen, Norway", year: "2023" },
{ id: "g17", src: "/img/gallery/g17.webp", alt: "Steam rising from a roadside tea stall wedged between train tracks, Varanasi, India", title: "Tea between trains", place: "Varanasi, India", year: "2017" },
{ id: "g18", src: "/img/gallery/g18.webp", alt: "A braided glacial river fanning across a grey gravel plain, Southern Alps, New Zealand", title: "Braided glacier melt", place: "Southern Alps, NZ", year: "2018" },
{ id: "g19", src: "/img/gallery/g19.webp", alt: "A narrow neon-lit alley reflected in wet pavement after midnight, Osaka, Japan", title: "Neon alley, midnight", place: "Osaka, Japan", year: "2022" },
{ id: "g20", src: "/img/gallery/g20.webp", alt: "A cypress-lined dirt road curving through a summer haze, Val d'Orcia, Italy", title: "Cypress road", place: "Val d'Orcia, Italy", year: "2019" },
{ id: "g21", src: "/img/gallery/g21.webp", alt: "Two ice climbers roped to a towering blue frozen waterfall, Ouray, Colorado", title: "The blue wall", place: "Ouray, Colorado", year: "2021" },
{ id: "g22", src: "/img/gallery/g22.webp", alt: "Brightly painted houses stacked up a steep hillside, Valparaiso, Chile", title: "Houses on the slope", place: "Valparaiso, Chile", year: "2016" },
{ id: "g23", src: "/img/gallery/g23.webp", alt: "An empty desert highway running straight toward distant dunes, Namib, Namibia", title: "No traffic for miles", place: "Namib, Namibia", year: "2018" },
{ id: "g24", src: "/img/gallery/g24.webp", alt: "Fog rolling beneath the deck of a long suspension bridge, San Francisco, California", title: "Fog under the bridge", place: "San Francisco, CA", year: "2020" },
];
const SPEEDS: { id: SpeedId; label: string; factor: number }[] = [
{ id: "slow", label: "Slow", factor: 0.6 },
{ id: "normal", label: "Normal", factor: 1 },
{ id: "fast", label: "Fast", factor: 1.6 },
];
const MASK_STYLE: CSSProperties = {
WebkitMaskImage:
"linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%)",
maskImage:
"linear-gradient(to right, transparent 0, #000 7%, #000 93%, transparent 100%)",
};
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M6 4.5v11l9-5.5-9-5.5Z" fill="currentColor" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M6 4.5h2.5v11H6zM11.5 4.5H14v11h-2.5z" fill="currentColor" />
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function ChevronIcon({ dir }: { dir: "left" | "right" }) {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
<path
d={dir === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"}
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function MqxImage() {
const reduce = useReducedMotion();
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState<SpeedId>("normal");
const [hoverPause, setHoverPause] = useState(true);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const speedFactor = useMemo(
() => SPEEDS.find((s) => s.id === speed)?.factor ?? 1,
[speed],
);
const rows = useMemo(() => {
const withIndex: Entry[] = PHOTOS.map((photo, index) => ({ photo, index }));
return [
{ key: "a", dir: "left" as const, dur: "42s", items: withIndex.slice(0, 8) },
{ key: "b", dir: "right" as const, dur: "54s", items: withIndex.slice(8, 16) },
{ key: "c", dir: "left" as const, dur: "48s", items: withIndex.slice(16, 24) },
];
}, []);
const openAt = useCallback((index: number, el: HTMLButtonElement) => {
triggerRef.current = el;
setOpenIndex(index);
}, []);
const close = useCallback(() => {
setOpenIndex(null);
triggerRef.current?.focus();
}, []);
const step = useCallback((delta: number) => {
setOpenIndex((cur) => {
if (cur === null) return cur;
return (cur + delta + PHOTOS.length) % PHOTOS.length;
});
}, []);
const isOpen = openIndex !== null;
useEffect(() => {
if (!isOpen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const raf = window.requestAnimationFrame(() => closeBtnRef.current?.focus());
return () => {
document.body.style.overflow = prevOverflow;
window.cancelAnimationFrame(raf);
};
}, [isOpen]);
const onDialogKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
close();
return;
}
if (e.key === "ArrowRight") {
e.preventDefault();
step(1);
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
step(-1);
return;
}
if (e.key === "Tab") {
const root = dialogRef.current;
if (!root) return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>(
'button, [href], [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute("disabled"));
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
},
[close, step],
);
const current = openIndex === null ? null : PHOTOS[openIndex];
const sectionStyle: StyleVars = { "--mqximg-speed": speedFactor };
return (
<section
className="relative w-full overflow-hidden bg-neutral-50 py-20 text-neutral-900 sm:py-24 dark:bg-neutral-950 dark:text-neutral-100"
style={sectionStyle}
data-playing={reduce ? "false" : playing ? "true" : "false"}
data-hoverpause={hoverPause ? "true" : "false"}
aria-labelledby="mqximg-heading"
>
<style>{`
.mqximg-track {
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-play-state: running;
will-change: transform;
}
[data-playing="false"] .mqximg-track { animation-play-state: paused; }
[data-hoverpause="true"] .mqximg-row:hover .mqximg-track,
[data-hoverpause="true"] .mqximg-row:focus-within .mqximg-track {
animation-play-state: paused;
}
@keyframes mqximg-scroll-left {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-50%, 0, 0); }
}
@keyframes mqximg-scroll-right {
from { transform: translate3d(-50%, 0, 0); }
to { transform: translate3d(0, 0, 0); }
}
@media (prefers-reduced-motion: reduce) {
.mqximg-track {
animation: none !important;
transform: none !important;
}
}
`}</style>
{/* Ambient backdrop */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(60% 55% at 15% 0%, rgba(99,102,241,0.14), transparent 60%), radial-gradient(50% 50% at 90% 10%, rgba(16,185,129,0.12), transparent 60%)",
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.035] dark:opacity-[0.06]"
style={{
backgroundImage:
"linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
/>
<div className="relative mx-auto max-w-7xl px-4 sm:px-6">
{/* Header */}
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-neutral-600 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
Field Archive
</span>
<h2
id="mqximg-heading"
className="mt-5 text-4xl font-semibold tracking-tight text-neutral-900 sm:text-5xl dark:text-neutral-50"
>
Frames from the road
</h2>
<p className="mt-4 text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
Twenty-four stills pulled from six years of carrying a single fixed
lens across four continents. Let the reel run, or tap any frame to
read where and when it was made.
</p>
</div>
{/* Controls */}
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center lg:flex-col lg:items-end">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
disabled={Boolean(reduce)}
aria-pressed={playing}
aria-label={playing ? "Pause the marquee" : "Play the marquee"}
className="inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 dark:focus-visible:ring-offset-neutral-950"
>
{playing ? <PauseIcon /> : <PlayIcon />}
{playing ? "Pause" : "Play"}
</button>
<div
role="group"
aria-label="Marquee speed"
className="inline-flex items-center rounded-full border border-neutral-300 bg-white/70 p-1 dark:border-neutral-700 dark:bg-neutral-900/70"
>
{SPEEDS.map((s) => {
const active = s.id === speed;
return (
<button
key={s.id}
type="button"
onClick={() => setSpeed(s.id)}
disabled={Boolean(reduce)}
aria-pressed={active}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-neutral-900 ${
active
? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900"
: "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
}`}
>
{s.label}
</button>
);
})}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={hoverPause}
onClick={() => setHoverPause((v) => !v)}
disabled={Boolean(reduce)}
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition-colors hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:text-neutral-400 dark:hover:text-neutral-100 dark:focus-visible:ring-offset-neutral-950"
>
<span
aria-hidden="true"
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
hoverPause
? "bg-emerald-500"
: "bg-neutral-300 dark:bg-neutral-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
hoverPause ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
Pause on hover
</button>
</div>
</div>
{reduce ? (
<p className="mt-6 text-sm text-neutral-500 dark:text-neutral-500">
Motion is reduced on your device, so the reel is paused. Scroll each
row sideways to browse, or select a frame to view it full size.
</p>
) : null}
</div>
{/* Marquee rows */}
<div className="relative mt-12 flex flex-col gap-6">
{rows.map((row) => {
const items = reduce ? row.items : [...row.items, ...row.items];
const trackStyle: CSSProperties = reduce
? {}
: {
animationName:
row.dir === "left"
? "mqximg-scroll-left"
: "mqximg-scroll-right",
animationDuration: `calc(${row.dur} / var(--mqximg-speed))`,
};
return (
<div
key={row.key}
className={`mqximg-row group relative ${
reduce ? "overflow-x-auto" : "overflow-hidden"
}`}
style={reduce ? undefined : MASK_STYLE}
>
<ul
className="mqximg-track flex w-max items-stretch gap-5 px-2"
style={trackStyle}
>
{items.map((entry, i) => {
const dup = !reduce && i >= row.items.length;
const p = entry.photo;
return (
<li
key={`${row.key}-${p.id}-${i}`}
aria-hidden={dup || undefined}
className="shrink-0"
>
<button
type="button"
tabIndex={dup ? -1 : 0}
onClick={(e) => openAt(entry.index, e.currentTarget)}
className="group/card relative block h-44 w-64 overflow-hidden rounded-2xl border border-neutral-200/80 bg-neutral-200 text-left shadow-sm transition-[transform,box-shadow] duration-300 hover:-translate-y-1 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 sm:h-52 sm:w-72 dark:border-neutral-800 dark:bg-neutral-800 dark:focus-visible:ring-offset-neutral-950"
aria-label={`View ${p.title}, ${p.place}, ${p.year}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.src}
alt={p.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-500 group-hover/card:scale-105"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent opacity-80"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 p-3">
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-white">
{p.title}
</span>
<span className="block truncate text-xs text-white/70">
{p.place}
</span>
</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-[10px] font-medium tracking-wide text-white/90 backdrop-blur-sm">
{p.year}
</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
);
})}
</div>
{/* Lightbox */}
{current ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
role="presentation"
onClick={close}
>
<div
aria-hidden="true"
className="absolute inset-0 bg-neutral-950/80 backdrop-blur-sm"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="mqximg-lightbox-title"
aria-describedby="mqximg-lightbox-desc"
onKeyDown={onDialogKeyDown}
onClick={(e) => e.stopPropagation()}
className="relative z-10 w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-neutral-900 shadow-2xl"
>
<div className="relative aspect-[3/2] w-full bg-neutral-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
<button
ref={closeBtnRef}
type="button"
onClick={close}
aria-label="Close viewer"
className="absolute right-3 top-3 inline-flex h-10 w-10 items-center justify-center rounded-full bg-neutral-950/60 text-white transition-colors hover:bg-neutral-950/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900"
>
<CloseIcon />
</button>
<button
type="button"
onClick={() => step(-1)}
aria-label="Previous photo"
className="absolute left-3 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-950/60 text-white transition-colors hover:bg-neutral-950/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900"
>
<ChevronIcon dir="left" />
</button>
<button
type="button"
onClick={() => step(1)}
aria-label="Next photo"
className="absolute right-3 top-1/2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-neutral-950/60 text-white transition-colors hover:bg-neutral-950/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900"
>
<ChevronIcon dir="right" />
</button>
</div>
<div className="flex items-start justify-between gap-4 p-5">
<div className="min-w-0">
<h3
id="mqximg-lightbox-title"
className="text-lg font-semibold text-white"
>
{current.title}
</h3>
<p
id="mqximg-lightbox-desc"
className="mt-1 text-sm text-neutral-400"
>
{current.place} · {current.year}
</p>
</div>
<span className="shrink-0 rounded-full border border-white/15 px-3 py-1 text-xs font-medium tabular-nums text-neutral-300">
{(openIndex ?? 0) + 1} / {PHOTOS.length}
</span>
</div>
</div>
</div>
) : null}
</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 →
Glowing Logo Marquee
OriginalA dual-row infinite logo marquee whose rows scroll in opposite directions, pause on hover and lift each logo with a shimmer sweep over an animated gradient glow.

Vertical Testimonial Marquee
OriginalThree vertical testimonial columns scroll at different speeds and opposite directions, pausing on hover with a shine sweep so reviews loop endlessly beside a sticky heading.

Velocity Band Marquee
OriginalA skewed dual-direction headline marquee of big gradient and outlined words plus a pill ticker, with speed and pause controls and pause on hover over an animated mesh background.

Logos Marquee
Originallogo marquee (generic marks)

Text Marquee
Originalscrolling text marquee

Cards Marquee
Originalcard marquee

Vertical Marquee
Originalvertical marquee columns

Gradient Fade Marquee
Originalmarquee with edge fade

Reverse Rows Marquee
Originaltwo rows scrolling opposite

Tilted Marquee
Originaltilted marquee band

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

