Marquee Gallery New
Rows of images drifting in opposite directions that pause on hover and speed up when you scroll or drag.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/gallery-marquee-gallery.json02
Or copy the source below (2 files). Dependencies: none.
Source
components/niceui/gallery/InfiniteMarqueeGallery.tsx
import { useEffect, useMemo, useRef } from 'react';
import { cn } from '@/lib/cn';
import { ARTWORKS, artwork } from './art';
export type MarqueeImage = { src: string; title: string; caption?: string };
export type InfiniteMarqueeGalleryProps = {
rows: MarqueeImage[][];
/** Base speed in px per second. */
speed?: number;
className?: string;
};
/** Rows of images drifting in opposite directions; scroll or drag velocity speeds them up. */
export function InfiniteMarqueeGallery({ rows, speed = 40, className }: InfiniteMarqueeGalleryProps) {
const root = useRef<HTMLDivElement>(null);
const tracks = useRef<(HTMLDivElement | null)[]>([]);
const state = useRef({ x: rows.map(() => 0), boost: 0, hover: false, drag: null as null | number });
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const s = state.current;
s.boost *= 0.92;
const base = reduce || s.hover ? 0 : speed;
tracks.current.forEach((el, i) => {
if (!el) return;
const dir = i % 2 === 0 ? -1 : 1;
const half = el.scrollWidth / 2;
s.x[i] += (dir * base + dir * s.boost) * dt;
if (half > 0) {
if (s.x[i] <= -half) s.x[i] += half;
if (s.x[i] > 0) s.x[i] -= half;
}
el.style.transform = `translate3d(${s.x[i]}px,0,0)`;
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onWheel = (e: WheelEvent) => {
state.current.boost = Math.min(900, state.current.boost + Math.abs(e.deltaY) * 2);
};
const el = root.current;
el?.addEventListener('wheel', onWheel, { passive: true });
return () => {
cancelAnimationFrame(raf);
el?.removeEventListener('wheel', onWheel);
};
}, [speed]);
return (
<div
ref={root}
className={cn('relative w-full touch-pan-y select-none space-y-3 overflow-hidden py-1 [mask-image:linear-gradient(90deg,transparent,black_10%,black_90%,transparent)]', className)}
onPointerEnter={() => (state.current.hover = true)}
onPointerLeave={() => {
state.current.hover = false;
state.current.drag = null;
}}
onPointerDown={(e) => (state.current.drag = e.clientX)}
onPointerMove={(e) => {
const s = state.current;
if (s.drag === null) return;
const dx = e.clientX - s.drag;
s.drag = e.clientX;
s.x = s.x.map((x, i) => x + (i % 2 === 0 ? dx : -dx));
s.boost = Math.min(900, s.boost + Math.abs(dx) * 4);
}}
onPointerUp={() => (state.current.drag = null)}
aria-label="Scrolling image gallery"
role="region"
>
{rows.map((row, i) => (
<div key={i} ref={(el) => (tracks.current[i] = el)} className="flex w-max gap-3 will-change-transform">
{[...row, ...row].map((img, j) => (
<figure key={j} className="group relative h-28 w-44 shrink-0 overflow-hidden rounded-xl bg-ink-850 shadow-card sm:h-36 sm:w-56" aria-hidden={j >= row.length}>
<img src={img.src} alt={img.title} draggable={false} className="h-full w-full object-cover transition-transform duration-700 ease-smooth group-hover:scale-105" />
<figcaption className="absolute inset-x-0 bottom-0 translate-y-1 bg-gradient-to-t from-black/80 to-transparent p-2.5 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100">
<p className="truncate text-[12px] font-medium text-white">{img.title}</p>
{img.caption && <p className="truncate text-[10.5px] text-white/60">{img.caption}</p>}
</figcaption>
</figure>
))}
</div>
))}
</div>
);
}
export function InfiniteMarqueeGalleryDemo() {
const rows = useMemo(() => {
const imgs = ARTWORKS.map((a) => ({ src: artwork(a.id, 560, 360), title: a.title, caption: `${a.artist} · ${a.place}` }));
return [imgs.slice(0, 6), imgs.slice(6, 12), [...imgs.slice(3, 9)].reverse()];
}, []);
return (
<div className="w-full">
<InfiniteMarqueeGallery rows={rows} />
<p className="mt-4 text-center text-xs text-ink-500">Hover to pause · scroll or drag to speed up</p>
</div>
);
}components/niceui/gallery/art.ts
/*
* Procedural artwork for the gallery demos: soft landscapes and abstract mesh
* gradients with film grain, rendered once to data URLs. Swap `src` for real
* photos in your app — every gallery component just takes image URLs.
*/
export type Artwork = { id: number; title: string; artist: string; place: string; year: number; tone: 'warm' | 'cool' | 'dusk' | 'mono' };
const PALETTES: string[][] = [
['#0b1d3a', '#1f4e79', '#f2a65a', '#f7d9a8'],
['#120c26', '#4b2a77', '#e66b9d', '#ffd1b3'],
['#06222b', '#0f5f6b', '#7fd1c7', '#e8f6f1'],
['#1b1410', '#5b3a26', '#d98c4a', '#f6e1c3'],
['#0e0f13', '#2b2f3a', '#8a93a6', '#e6e8ee'],
['#0a1a12', '#1f5134', '#9cc98a', '#f1f7da'],
['#1a0f14', '#6b1f3a', '#ef6f5e', '#ffd8a8'],
['#071426', '#123f73', '#5aa9e6', '#d6ecff'],
['#191325', '#3b2d5c', '#b39ddb', '#f3e9ff'],
['#201608', '#6e4a12', '#e0b34a', '#fff1c4'],
['#0b1216', '#29424f', '#6fa3b8', '#f0e6d2'],
['#170b0b', '#4a1414', '#c84b31', '#f5c26b'],
];
export const ARTWORKS: Artwork[] = [
{ id: 0, title: 'Low Tide, Dawn', artist: 'Mira Okafor', place: 'Comporta', year: 2025, tone: 'warm' },
{ id: 1, title: 'Neon Bloom', artist: 'Ilya Varga', place: 'Kyoto', year: 2024, tone: 'dusk' },
{ id: 2, title: 'Glass Lagoon', artist: 'Sana Iqbal', place: 'Faroe Islands', year: 2025, tone: 'cool' },
{ id: 3, title: 'Copper Dunes', artist: 'Teo Marchetti', place: 'Merzouga', year: 2023, tone: 'warm' },
{ id: 4, title: 'Fog Study No. 4', artist: 'Lena Brandt', place: 'Dolomites', year: 2024, tone: 'mono' },
{ id: 5, title: 'Green Hour', artist: 'Ravi Menon', place: 'Munnar', year: 2025, tone: 'cool' },
{ id: 6, title: 'Ember Coast', artist: 'Noor Haddad', place: 'Big Sur', year: 2024, tone: 'warm' },
{ id: 7, title: 'Blue Hour Harbour', artist: 'Jonas Weber', place: 'Bergen', year: 2025, tone: 'cool' },
{ id: 8, title: 'Lavender Field', artist: 'Camille Roy', place: 'Valensole', year: 2023, tone: 'dusk' },
{ id: 9, title: 'Golden Ridge', artist: 'Aiko Tanaka', place: 'Hokkaido', year: 2025, tone: 'warm' },
{ id: 10, title: 'Salt Flats', artist: 'Diego Alvarez', place: 'Uyuni', year: 2024, tone: 'mono' },
{ id: 11, title: 'Red Canyon', artist: 'Hana Kobayashi', place: 'Arizona', year: 2025, tone: 'warm' },
];
let seedState = 1;
const rand = () => {
seedState = (seedState * 1664525 + 1013904223) >>> 0;
return seedState / 4294967296;
};
const cache = new Map<string, string>();
/** A deterministic artwork as a data URL. `kind` picks a landscape or an abstract mesh. */
export function artwork(id: number, w = 960, h = 640, kind: 'landscape' | 'mesh' = id % 3 === 1 ? 'mesh' : 'landscape'): string {
const key = `${id}-${w}-${h}-${kind}`;
const hit = cache.get(key);
if (hit) return hit;
if (typeof document === 'undefined') return '';
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
if (!g) return '';
seedState = (id + 1) * 9973;
const [deep, mid, hot, light] = PALETTES[id % PALETTES.length];
const sky = g.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, deep);
sky.addColorStop(0.55, mid);
sky.addColorStop(1, kind === 'mesh' ? deep : hot);
g.fillStyle = sky;
g.fillRect(0, 0, w, h);
// soft light blobs
const blobs = kind === 'mesh' ? 6 : 3;
for (let i = 0; i < blobs; i++) {
const x = rand() * w;
const y = rand() * h * (kind === 'mesh' ? 1 : 0.6);
const r = (0.25 + rand() * 0.45) * Math.max(w, h);
const rg = g.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, [hot, light, mid][i % 3] + 'cc');
rg.addColorStop(1, 'transparent');
g.globalCompositeOperation = 'screen';
g.fillStyle = rg;
g.fillRect(0, 0, w, h);
}
g.globalCompositeOperation = 'source-over';
if (kind === 'landscape') {
// sun
const sx = (0.25 + rand() * 0.5) * w;
const sy = h * (0.38 + rand() * 0.12);
const sr = Math.min(w, h) * (0.07 + rand() * 0.05);
const glow = g.createRadialGradient(sx, sy, 0, sx, sy, sr * 5);
glow.addColorStop(0, light + 'aa');
glow.addColorStop(1, 'transparent');
g.fillStyle = glow;
g.fillRect(0, 0, w, h);
g.fillStyle = light;
g.beginPath();
g.arc(sx, sy, sr, 0, Math.PI * 2);
g.fill();
// ridges, back to front
for (let layer = 0; layer < 4; layer++) {
const base = h * (0.5 + layer * 0.12);
const amp = h * (0.12 - layer * 0.02);
const f1 = 1.5 + rand() * 2;
const f2 = 4 + rand() * 4;
const ph = rand() * 10;
g.beginPath();
g.moveTo(0, h);
for (let x = 0; x <= w; x += 4) {
const u = x / w;
const y = base - amp * (0.6 * Math.sin(u * f1 * Math.PI + ph) + 0.3 * Math.sin(u * f2 * Math.PI + ph * 2) + 0.1 * Math.sin(u * 23 + ph));
g.lineTo(x, y);
}
g.lineTo(w, h);
g.closePath();
const t = layer / 3;
const lg = g.createLinearGradient(0, base - amp, 0, h);
lg.addColorStop(0, mix(mid, deep, 0.25 + t * 0.6));
lg.addColorStop(1, mix(deep, '#000000', t * 0.5));
g.fillStyle = lg;
g.globalAlpha = 0.92;
g.fill();
g.globalAlpha = 1;
}
} else {
// flowing ribbons
for (let i = 0; i < 3; i++) {
g.beginPath();
const y0 = h * (0.2 + rand() * 0.6);
g.moveTo(-50, y0);
g.bezierCurveTo(w * 0.3, y0 - h * (rand() - 0.5), w * 0.7, y0 + h * (rand() - 0.5), w + 50, y0 + h * (rand() - 0.5) * 0.6);
g.lineWidth = Math.min(w, h) * (0.08 + rand() * 0.12);
g.strokeStyle = [light, hot, mid][i] + '55';
g.filter = `blur(${Math.round(Math.min(w, h) * 0.04)}px)`;
g.stroke();
g.filter = 'none';
}
}
// vignette
const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.75);
vg.addColorStop(0, 'transparent');
vg.addColorStop(1, 'rgba(0,0,0,0.45)');
g.fillStyle = vg;
g.fillRect(0, 0, w, h);
// film grain
const img = g.getImageData(0, 0, w, h);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const n = (rand() - 0.5) * 18;
d[i] += n;
d[i + 1] += n;
d[i + 2] += n;
}
g.putImageData(img, 0, 0);
const url = c.toDataURL('image/jpeg', 0.86);
cache.set(key, url);
return url;
}
function mix(a: string, b: string, t: number) {
const pa = parseInt(a.slice(1), 16);
const pb = parseInt(b.slice(1), 16);
const ch = (s: number) => Math.round(((pa >> s) & 255) * (1 - t) + ((pb >> s) & 255) * t);
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
}