3D Gallery Wall New
Framed artworks hung along a perspective wall; walk along it by dragging or with the arrow keys, and the focused piece steps forward.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/gallery-gallery-wall.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/gallery/Gallery3DWall.tsx
import { useMemo, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ARTWORKS, artwork } from './art';
export type WallArtwork = { src: string; title: string; artist: string; year: number | string; place?: string };
export type Gallery3DWallProps = {
items: WallArtwork[];
initial?: number;
className?: string;
};
/** A perspective gallery wall. Drag, swipe or use the arrow keys to walk along it. */
export function Gallery3DWall({ items, initial = 0, className }: Gallery3DWallProps) {
const [active, setActive] = useState(initial);
const drag = useRef<{ x: number; moved: boolean } | null>(null);
const go = (i: number) => setActive(Math.max(0, Math.min(items.length - 1, i)));
const cur = items[active];
return (
<div
className={cn('relative w-full select-none overflow-hidden rounded-2xl bg-gradient-to-b from-ink-900 via-ink-950 to-ink-900 shadow-card', className)}
tabIndex={0}
role="region"
aria-roledescription="carousel"
aria-label="3D gallery wall"
onKeyDown={(e) => {
if (e.key === 'ArrowRight') go(active + 1);
if (e.key === 'ArrowLeft') go(active - 1);
}}
onPointerDown={(e) => (drag.current = { x: e.clientX, moved: false })}
onPointerMove={(e) => {
const d = drag.current;
if (!d) return;
const dx = e.clientX - d.x;
if (Math.abs(dx) > 50) {
go(active + (dx < 0 ? 1 : -1));
drag.current = { x: e.clientX, moved: true };
}
}}
onPointerUp={() => (drag.current = null)}
onPointerLeave={() => (drag.current = null)}
>
{/* floor and ceiling lines */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 [background:repeating-linear-gradient(90deg,rgba(255,255,255,0.035)_0_1px,transparent_1px_60px)] [mask-image:linear-gradient(transparent,black)] [transform:perspective(500px)_rotateX(60deg)] origin-bottom" />
<div className="pointer-events-none absolute left-1/2 top-0 h-40 w-[70%] -translate-x-1/2 bg-[radial-gradient(ellipse_at_top,rgba(125,211,252,0.12),transparent_70%)]" />
<div className="relative h-[300px] [perspective:1100px] sm:h-[340px]">
<div className="absolute inset-0 [transform-style:preserve-3d]">
{items.map((it, i) => {
const off = i - active;
const abs = Math.abs(off);
if (abs > 4) return null;
return (
<button
key={i}
tabIndex={-1}
onClick={() => !drag.current?.moved && go(i)}
aria-label={`${it.title} by ${it.artist}`}
className="absolute left-1/2 top-1/2 w-[42%] max-w-[250px] transition-all duration-700 ease-smooth sm:w-[30%]"
style={{
transform: `translate(-50%, -50%) translateX(${off * 62}%) translateZ(${off === 0 ? 40 : -abs * 70}px) rotateY(${off === 0 ? 0 : off < 0 ? 38 : -38}deg)`,
zIndex: 10 - abs,
opacity: abs > 3 ? 0 : 1,
}}
>
<span className={cn('block rounded-[6px] bg-[#e9e3d6] p-2.5 shadow-[0_30px_60px_-20px_rgba(0,0,0,0.8)] transition-[filter] duration-700 sm:p-3.5', off !== 0 && 'brightness-[0.55]')}>
<span className="block bg-white/90 p-1.5 shadow-inner">
<img src={it.src} alt="" draggable={false} className="aspect-[4/5] w-full object-cover" />
</span>
</span>
<span className={cn('absolute -bottom-6 left-1/2 h-6 w-[80%] -translate-x-1/2 rounded-full bg-black/60 blur-xl transition-opacity', off === 0 ? 'opacity-100' : 'opacity-40')} />
</button>
);
})}
</div>
</div>
<div className="relative flex items-end justify-between gap-4 border-t border-white/5 px-4 py-3.5 sm:px-6">
<div key={active} className="min-w-0 animate-fade-up">
<p className="truncate font-serif text-xl italic text-ink-50">{cur.title}</p>
<p className="truncate text-xs text-ink-400">
{cur.artist}
{cur.place ? ` · ${cur.place}` : ''} · {cur.year}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-mono text-[11px] tabular-nums text-ink-500">
{String(active + 1).padStart(2, '0')} / {String(items.length).padStart(2, '0')}
</span>
<button onClick={() => go(active - 1)} disabled={active === 0} aria-label="Previous artwork" className="focus-ring grid h-8 w-8 place-items-center rounded-full bg-white/5 text-ink-200 transition-colors hover:bg-white/10 disabled:opacity-30">
<ChevronLeft className="h-4 w-4" />
</button>
<button onClick={() => go(active + 1)} disabled={active === items.length - 1} aria-label="Next artwork" className="focus-ring grid h-8 w-8 place-items-center rounded-full bg-white/5 text-ink-200 transition-colors hover:bg-white/10 disabled:opacity-30">
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export function Gallery3DWallDemo() {
const items = useMemo(() => ARTWORKS.slice(0, 9).map((a) => ({ src: artwork(a.id, 480, 600), title: a.title, artist: a.artist, year: a.year, place: a.place })), []);
return <Gallery3DWall items={items} initial={3} className="mx-auto max-w-4xl" />;
}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)})`;
}