Parallax Slider New
Full-width slides whose images travel slower than the frame, with large serif captions, segmented autoplay progress and pause.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/gallery-parallax-carousel.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/gallery/ParallaxCarousel.tsx
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { ArrowLeft, ArrowRight, Pause, Play } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ARTWORKS, artwork, type Artwork } from './art';
export type ParallaxCarouselProps = {
items?: Artwork[];
src?: (item: Artwork) => string;
/** Autoplay interval in ms. 0 disables autoplay. */
interval?: number;
className?: string;
};
/** Full-width slides whose images travel slower than the frame, with serif captions and an autoplay progress bar. */
export function ParallaxCarousel({ items = ARTWORKS.slice(0, 6), src = (a) => artwork(a.id, 1400, 900), interval = 5000, className }: ParallaxCarouselProps) {
const [index, setIndex] = useState(0);
const [dx, setDx] = useState(0);
const [dragging, setDragging] = useState(false);
const [paused, setPaused] = useState(false);
const [hover, setHover] = useState(false);
const [progress, setProgress] = useState(0);
const [width, setWidth] = useState(800);
const frame = useRef<HTMLDivElement>(null);
const startX = useRef(0);
const progressRef = useRef(0);
const urls = useMemo(() => items.map(src), [items, src]);
const n = items.length;
useEffect(() => {
const el = frame.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}, []);
const go = (i: number) => {
setIndex(((i % n) + n) % n);
progressRef.current = 0;
setProgress(0);
};
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!interval || paused || hover || dragging || reduce) return;
let raf = 0;
const startAt = performance.now() - progressRef.current * interval;
let begin = startAt;
const tick = (t: number) => {
let p = (t - begin) / interval;
if (p >= 1) {
setIndex((i) => (i + 1) % n);
begin = t;
p = 0;
}
progressRef.current = p;
setProgress(p);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [interval, paused, hover, dragging, n, index]);
const onDown = (e: PointerEvent) => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startX.current = e.clientX;
setDragging(true);
};
const onMove = (e: PointerEvent) => dragging && setDx(e.clientX - startX.current);
const onUp = () => {
if (!dragging) return;
setDragging(false);
if (dx < -width * 0.15) go(index + 1);
else if (dx > width * 0.15) go(index - 1);
setDx(0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') (e.preventDefault(), go(index + 1));
if (e.key === 'ArrowLeft') (e.preventDefault(), go(index - 1));
};
const cur = items[index];
return (
<div className={cn('w-full', className)} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<div
ref={frame}
role="region"
aria-roledescription="carousel"
aria-label="Parallax slider"
tabIndex={0}
onKeyDown={onKey}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="focus-ring relative aspect-[4/5] w-full cursor-grab touch-pan-y select-none overflow-hidden rounded-3xl bg-ink-900 shadow-pop ring-1 ring-white/10 active:cursor-grabbing sm:aspect-[16/9]"
>
<div className={cn('absolute inset-0 flex', dragging ? '' : 'transition-transform duration-700 ease-smooth')} style={{ transform: `translateX(${-index * width + dx}px)` }}>
{items.map((it, i) => {
const offset = (i * width - index * width + dx) / width;
return (
<div key={it.id} className="relative h-full shrink-0 overflow-hidden" style={{ width }} aria-hidden={i !== index}>
<img
src={urls[i]}
alt={it.title}
draggable={false}
className={cn('absolute inset-y-0 left-[-20%] h-full w-[140%] max-w-none object-cover', dragging ? '' : 'transition-transform duration-700 ease-smooth')}
style={{ transform: `translateX(${-offset * 38}%) scale(${1.04 - Math.min(0.04, Math.abs(offset) * 0.04)})` }}
/>
</div>
);
})}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-black/30" />
<div className="pointer-events-none absolute left-5 top-5 font-mono text-[11px] tracking-wider text-white/70 sm:left-8 sm:top-7">
{String(index + 1).padStart(2, '0')} <span className="text-white/35">/ {String(n).padStart(2, '0')}</span>
</div>
<div className="pointer-events-none absolute inset-x-5 bottom-6 sm:inset-x-8 sm:bottom-8">
<p key={`p-${cur.id}`} className="text-xs uppercase tracking-[0.2em] text-white/60 animate-fade-up">
{cur.place} · {cur.year}
</p>
<h3 key={`t-${cur.id}`} className="mt-2 max-w-xl font-serif text-[40px] italic leading-[0.95] text-white animate-fade-up sm:text-[64px]" style={{ animationDelay: '80ms' }}>
{cur.title}
</h3>
<p key={`a-${cur.id}`} className="mt-3 text-sm text-white/70 animate-fade-up" style={{ animationDelay: '160ms' }}>
by {cur.artist}
</p>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<div className="flex flex-1 gap-1.5">
{items.map((it, i) => (
<button key={it.id} onClick={() => go(i)} aria-label={`Go to slide ${i + 1}`} className="focus-ring group relative h-6 flex-1">
<span className="absolute inset-x-0 top-1/2 h-[3px] -translate-y-1/2 overflow-hidden rounded-full bg-white/10 transition group-hover:bg-white/20">
<span className="block h-full rounded-full bg-white" style={{ width: i < index ? '100%' : i === index ? `${progress * 100}%` : '0%' }} />
</span>
</button>
))}
</div>
<button onClick={() => setPaused((p) => !p)} aria-label={paused ? 'Play' : 'Pause'} className="focus-ring grid h-9 w-9 place-items-center rounded-full bg-white/5 text-ink-200 ring-1 ring-white/10 transition hover:bg-white/10">
{paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
</button>
<button onClick={() => go(index - 1)} aria-label="Previous slide" className="focus-ring grid h-9 w-9 place-items-center rounded-full bg-white/5 text-ink-200 ring-1 ring-white/10 transition hover:bg-white/10">
<ArrowLeft className="h-4 w-4" />
</button>
<button onClick={() => go(index + 1)} aria-label="Next slide" className="focus-ring grid h-9 w-9 place-items-center rounded-full bg-white text-ink-950 transition hover:bg-ink-100">
<ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
export function ParallaxCarouselDemo() {
return (
<div className="mx-auto w-full max-w-3xl">
<ParallaxCarousel />
</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)})`;
}