Card Stack New
A swipeable stack of photo cards that tilt as you drag and fly out left or right, with keep and skip buttons, keyboard control and restack.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/gallery-stack-carousel.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/gallery/StackCarousel.tsx
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { Heart, RotateCcw, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { ARTWORKS, artwork, type Artwork } from './art';
export type StackCarouselProps = {
items?: Artwork[];
src?: (item: Artwork) => string;
onSwipe?: (item: Artwork, dir: 'left' | 'right') => void;
className?: string;
};
const THRESHOLD = 110;
/** Swipeable photo stack: drag the top card, it tilts, then flies out left or right. */
export function StackCarousel({ items = ARTWORKS.slice(0, 7), src = (a) => artwork(a.id, 600, 760), onSwipe, className }: StackCarouselProps) {
const [deck, setDeck] = useState(items);
const [drag, setDrag] = useState({ x: 0, y: 0, active: false });
const [fly, setFly] = useState<'left' | 'right' | null>(null);
const [kept, setKept] = useState<Artwork[]>([]);
const [skipped, setSkipped] = useState(0);
const start = useRef({ x: 0, y: 0 });
const timer = useRef<number>();
const urls = useMemo(() => new Map(items.map((a) => [a.id, src(a)])), [items, src]);
useEffect(() => () => window.clearTimeout(timer.current), []);
const top = deck[0];
const swipe = (dir: 'left' | 'right') => {
if (!top || fly) return;
setFly(dir);
onSwipe?.(top, dir);
if (dir === 'right') setKept((k) => [top, ...k].slice(0, 5));
else setSkipped((s) => s + 1);
timer.current = window.setTimeout(() => {
setDeck((d) => d.slice(1));
setFly(null);
setDrag({ x: 0, y: 0, active: false });
}, 320);
};
const restack = () => {
setDeck(items);
setKept([]);
setSkipped(0);
};
const onDown = (e: PointerEvent) => {
if (fly) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
start.current = { x: e.clientX, y: e.clientY };
setDrag({ x: 0, y: 0, active: true });
};
const onMove = (e: PointerEvent) => {
if (!drag.active) return;
setDrag({ x: e.clientX - start.current.x, y: (e.clientY - start.current.y) * 0.4, active: true });
};
const onUp = () => {
if (!drag.active) return;
if (Math.abs(drag.x) > THRESHOLD) swipe(drag.x > 0 ? 'right' : 'left');
else setDrag({ x: 0, y: 0, active: false });
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') (e.preventDefault(), swipe('left'));
if (e.key === 'ArrowRight') (e.preventDefault(), swipe('right'));
};
const x = fly ? (fly === 'right' ? 640 : -640) : drag.x;
const intent = Math.max(-1, Math.min(1, x / THRESHOLD));
return (
<div className={cn('flex w-full flex-col items-center gap-5 sm:flex-row sm:items-center sm:justify-center sm:gap-10', className)}>
<div className="flex flex-col items-center gap-5">
<div
role="region"
aria-label="Photo stack. Use left and right arrow keys to skip or keep."
tabIndex={0}
onKeyDown={onKey}
className="focus-ring relative h-[340px] w-[256px] rounded-3xl"
>
{deck.length === 0 && (
<div className="absolute inset-0 grid place-items-center rounded-3xl border border-dashed border-white/15 text-center animate-fade-in">
<div>
<p className="text-sm text-ink-200">That’s the whole stack.</p>
<button onClick={restack} className="focus-ring mt-3 inline-flex items-center gap-1.5 rounded-full bg-white px-3.5 py-1.5 text-xs font-medium text-ink-950">
<RotateCcw className="h-3.5 w-3.5" /> Restack
</button>
</div>
</div>
)}
{deck
.slice(0, 4)
.map((it, i) => {
const isTop = i === 0;
const depth = isTop ? 0 : i - Math.min(1, Math.abs(intent)) * (i === 1 ? 1 : 0.5);
const style = isTop
? { transform: `translate(${x}px, ${drag.y}px) rotate(${x / 14}deg)`, zIndex: 10 }
: { transform: `translateY(${depth * 14}px) scale(${1 - depth * 0.05}) rotate(${(i % 2 ? 1 : -1) * depth * 2}deg)`, zIndex: 10 - i };
return (
<div
key={it.id}
onPointerDown={isTop ? onDown : undefined}
onPointerMove={isTop ? onMove : undefined}
onPointerUp={isTop ? onUp : undefined}
onPointerCancel={isTop ? onUp : undefined}
className={cn(
'absolute inset-0 overflow-hidden rounded-3xl bg-ink-900 shadow-pop ring-1 ring-white/10',
isTop ? 'cursor-grab touch-none active:cursor-grabbing' : '',
drag.active && isTop ? '' : 'transition-transform duration-300 ease-smooth',
)}
style={{ ...style, opacity: fly && isTop ? 0 : 1, transition: fly && isTop ? 'transform 320ms cubic-bezier(.4,0,1,1), opacity 320ms' : undefined }}
>
<img src={urls.get(it.id)} alt={it.title} draggable={false} className="h-full w-full select-none object-cover" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/40 to-transparent p-5 pt-16">
<p className="font-serif text-2xl italic leading-tight text-white">{it.title}</p>
<p className="mt-1 text-xs text-white/70">
{it.artist} · {it.place}
</p>
</div>
{isTop && (
<>
<span className="absolute left-4 top-4 rotate-[-12deg] rounded-lg border-2 border-emerald-400 px-2 py-0.5 text-sm font-bold uppercase tracking-wider text-emerald-300" style={{ opacity: Math.max(0, intent) }}>
Keep
</span>
<span className="absolute right-4 top-4 rotate-[12deg] rounded-lg border-2 border-rose-400 px-2 py-0.5 text-sm font-bold uppercase tracking-wider text-rose-300" style={{ opacity: Math.max(0, -intent) }}>
Skip
</span>
</>
)}
</div>
);
})
.reverse()}
</div>
<div className="flex items-center gap-3">
<button onClick={() => swipe('left')} disabled={!top} aria-label="Skip" className="focus-ring grid h-12 w-12 place-items-center rounded-full bg-white/5 text-rose-300 ring-1 ring-white/10 transition hover:scale-105 hover:bg-rose-500/10 active:scale-95 disabled:opacity-30">
<X className="h-5 w-5" />
</button>
<button onClick={restack} aria-label="Restack" className="focus-ring grid h-9 w-9 place-items-center rounded-full bg-white/5 text-ink-300 ring-1 ring-white/10 transition hover:bg-white/10">
<RotateCcw className="h-4 w-4" />
</button>
<button onClick={() => swipe('right')} disabled={!top} aria-label="Keep" className="focus-ring grid h-12 w-12 place-items-center rounded-full bg-white/5 text-emerald-300 ring-1 ring-white/10 transition hover:scale-105 hover:bg-emerald-500/10 active:scale-95 disabled:opacity-30">
<Heart className="h-5 w-5" />
</button>
</div>
</div>
<div className="w-full max-w-[256px] sm:w-48">
<p className="font-mono text-[11px] uppercase tracking-wider text-ink-500">Kept · {kept.length}</p>
<div className="mt-2 flex gap-1.5 sm:flex-col">
{kept.length === 0 && <p className="text-xs text-ink-500">Swipe right, press →, or tap the heart.</p>}
{kept.map((k) => (
<div key={k.id} className="flex min-w-0 items-center gap-2 rounded-lg bg-white/[0.03] p-1.5 ring-1 ring-white/5 animate-fade-up">
<img src={urls.get(k.id)} alt="" className="h-9 w-9 shrink-0 rounded-md object-cover" />
<span className="hidden min-w-0 truncate text-xs text-ink-200 sm:block">{k.title}</span>
</div>
))}
</div>
<p className="mt-3 text-xs text-ink-500">Skipped {skipped}</p>
</div>
</div>
);
}
export function StackCarouselDemo() {
return <StackCarousel />;
}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)})`;
}