Showreel Player New
A video-style showreel with slow pan-and-zoom, a scrubber with chapter markers and hover previews, a chapter list and timecode.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/gallery-showreel-player.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/gallery/ShowreelPlayer.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { Maximize2, Minimize2, Pause, Play, SkipBack, SkipForward } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ARTWORKS, artwork } from './art';
export type Chapter = { title: string; image: string; duration: number };
const fmt = (s: number) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
export function ShowreelPlayer({ chapters, autoPlay = true, className }: { chapters: Chapter[]; autoPlay?: boolean; className?: string }) {
const total = chapters.reduce((a, c) => a + c.duration, 0);
const starts = useMemo(() => chapters.reduce<number[]>((acc, c, i) => [...acc, i ? acc[i - 1] + chapters[i - 1].duration : 0], []), [chapters]);
const [t, setT] = useState(0);
const [playing, setPlaying] = useState(false);
const [full, setFull] = useState(false);
const [hover, setHover] = useState<number | null>(null);
const bar = useRef<HTMLDivElement>(null);
const drag = useRef(false);
useEffect(() => {
if (autoPlay && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) setPlaying(true);
}, [autoPlay]);
useEffect(() => {
if (!playing) return;
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const dt = Math.max(0, now - last) / 1000;
last = now;
setT((v) => (v + dt >= total ? 0 : v + dt));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [playing, total]);
const idx = Math.max(0, starts.findIndex((s, i) => t >= s && t < s + chapters[i].duration));
const local = (t - starts[idx]) / chapters[idx].duration;
const at = (x: number) => {
const r = bar.current!.getBoundingClientRect();
return Math.min(total - 0.01, Math.max(0, ((x - r.left) / r.width) * total));
};
const jump = (i: number) => setT(starts[(i + chapters.length) % chapters.length] + 0.01);
const hoverIdx = hover === null ? null : Math.max(0, starts.findIndex((s, i) => hover >= s && hover < s + chapters[i].duration));
return (
<div className={cn('w-full', full ? 'absolute inset-0 z-20 flex items-center bg-black p-3' : 'relative', className)}>
<div className="grid w-full gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div
className="group relative aspect-video overflow-hidden rounded-2xl bg-black shadow-card"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === ' ') (e.preventDefault(), setPlaying((p) => !p));
if (e.key === 'ArrowRight') setT((v) => Math.min(total - 0.01, v + 2));
if (e.key === 'ArrowLeft') setT((v) => Math.max(0, v - 2));
}}
aria-label="Showreel player"
>
{chapters.map((c, i) => (
<img
key={i}
src={c.image}
alt=""
className="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
style={{ opacity: i === idx ? 1 : 0, transform: i === idx ? `scale(${1.04 + local * 0.1}) translate(${(i % 2 ? -1 : 1) * local * 2}%, ${local * -1.5}%)` : 'scale(1.04)' }}
/>
))}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/30" />
<div className="absolute left-4 top-3 flex items-center gap-2 font-mono text-[10.5px] uppercase tracking-wider text-white/70">
<span className={cn('h-1.5 w-1.5 rounded-full', playing ? 'animate-pulse bg-rose-500' : 'bg-white/40')} /> Reel 2026 · Ch {idx + 1}/{chapters.length}
</div>
<p key={idx} className="absolute bottom-16 left-4 font-serif text-2xl italic text-white animate-fade-up sm:text-3xl">{chapters[idx].title}</p>
<button onClick={() => setPlaying((p) => !p)} aria-label={playing ? 'Pause' : 'Play'} className={cn('focus-ring absolute left-1/2 top-1/2 grid h-14 w-14 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white/15 text-white backdrop-blur transition-opacity', playing ? 'opacity-0 group-hover:opacity-100' : 'opacity-100')}>
{playing ? <Pause className="h-5 w-5 fill-current" /> : <Play className="ml-0.5 h-5 w-5 fill-current" />}
</button>
<div className="absolute inset-x-3 bottom-3 space-y-2">
<div
ref={bar}
className="relative h-5 cursor-pointer touch-none"
onPointerDown={(e) => {
drag.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setT(at(e.clientX));
}}
onPointerMove={(e) => {
setHover(at(e.clientX));
if (drag.current) setT(at(e.clientX));
}}
onPointerUp={() => (drag.current = false)}
onPointerLeave={() => setHover(null)}
>
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/20" />
<div className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-brand-400" style={{ width: `${(t / total) * 100}%` }} />
{starts.slice(1).map((s) => (
<span key={s} className="absolute top-1/2 h-2.5 w-0.5 -translate-y-1/2 rounded bg-white/60" style={{ left: `${(s / total) * 100}%` }} />
))}
<span className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow" style={{ left: `${(t / total) * 100}%` }} />
{hover !== null && hoverIdx !== null && (
<div className="pointer-events-none absolute bottom-6 hidden w-28 -translate-x-1/2 overflow-hidden rounded-lg border border-white/20 bg-black shadow-pop sm:block" style={{ left: `clamp(56px, ${(hover / total) * 100}%, calc(100% - 56px))` }}>
<img src={chapters[hoverIdx].image} alt="" className="aspect-video w-full object-cover" />
<p className="truncate px-1.5 py-1 font-mono text-[9px] text-white/80">{fmt(hover)} · {chapters[hoverIdx].title}</p>
</div>
)}
</div>
<div className="flex items-center gap-1 text-white">
<button onClick={() => jump(idx - 1)} aria-label="Previous chapter" className="focus-ring grid h-7 w-7 place-items-center rounded-md hover:bg-white/10"><SkipBack className="h-3.5 w-3.5" /></button>
<button onClick={() => setPlaying((p) => !p)} aria-label={playing ? 'Pause' : 'Play'} className="focus-ring grid h-7 w-7 place-items-center rounded-md hover:bg-white/10">{playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}</button>
<button onClick={() => jump(idx + 1)} aria-label="Next chapter" className="focus-ring grid h-7 w-7 place-items-center rounded-md hover:bg-white/10"><SkipForward className="h-3.5 w-3.5" /></button>
<span className="ml-1 font-mono text-[11px] tabular-nums text-white/80">{fmt(t)} / {fmt(total)}</span>
<button onClick={() => setFull((f) => !f)} aria-label={full ? 'Exit full view' : 'Full view'} className="focus-ring ml-auto grid h-7 w-7 place-items-center rounded-md hover:bg-white/10">{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}</button>
</div>
</div>
</div>
{!full && (
<ol className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
{chapters.map((c, i) => (
<li key={i} className="shrink-0 lg:shrink">
<button onClick={() => jump(i)} className={cn('focus-ring flex w-44 items-center gap-2.5 rounded-xl p-1.5 text-left transition-colors lg:w-full', i === idx ? 'bg-white/[0.07]' : 'hover:bg-white/[0.04]')}>
<span className="relative h-10 w-16 shrink-0 overflow-hidden rounded-md">
<img src={c.image} alt="" className="h-full w-full object-cover" />
{i === idx && <span className="absolute bottom-0 left-0 h-0.5 bg-brand-400" style={{ width: `${local * 100}%` }} />}
</span>
<span className="min-w-0">
<span className={cn('block truncate text-[12.5px]', i === idx ? 'text-ink-50' : 'text-ink-300')}>{c.title}</span>
<span className="font-mono text-[10px] text-ink-500">{fmt(starts[i])}</span>
</span>
</button>
</li>
))}
</ol>
)}
</div>
</div>
);
}
export function ShowreelPlayerDemo() {
const chapters = useMemo(() => [0, 2, 3, 6, 9].map((id, i) => ({ title: ARTWORKS[id].title, image: artwork(id, 960, 540), duration: [6, 5, 7, 5, 6][i] })), []);
return (
<div className="relative w-full">
<ShowreelPlayer chapters={chapters} />
</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)})`;
}