Cart Drawer New
A slide-over cart with quantity steppers, remove with undo, a free-shipping progress bar, an upsell and a checkout button.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/commerce-cart-drawer.json02
Or copy the source below (4 files). Dependencies: lucide-react.
Source
components/niceui/commerce/CartDrawer.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowRight, Lock, Minus, Plus, ShoppingBag, Trash2, Truck, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { PRODUCTS, money, type Product } from './data';
import { Print } from './Print';
export type CartLine = { key: string; product: Product; frame: string; size: string; qty: number };
const FREE_SHIPPING = 150;
export type CartDrawerProps = {
open: boolean;
onClose: () => void;
lines: CartLine[];
onChange: (lines: CartLine[]) => void;
upsell?: Product;
onCheckout?: () => void;
};
/** A cart drawer that slides over its nearest positioned parent. */
export function CartDrawer({ open, onClose, lines, onChange, upsell, onCheckout }: CartDrawerProps) {
const [removed, setRemoved] = useState<{ line: CartLine; index: number } | null>(null);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const subtotal = lines.reduce((s, l) => s + l.product.price * l.qty, 0);
const left = Math.max(0, FREE_SHIPPING - subtotal);
const count = lines.reduce((s, l) => s + l.qty, 0);
const setQty = (key: string, q: number) => onChange(lines.map((l) => (l.key === key ? { ...l, qty: Math.max(1, Math.min(9, q)) } : l)));
const remove = (key: string) => {
const index = lines.findIndex((l) => l.key === key);
setRemoved({ line: lines[index], index });
onChange(lines.filter((l) => l.key !== key));
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setRemoved(null), 5000);
};
const undo = () => {
if (!removed) return;
const next = [...lines];
next.splice(removed.index, 0, removed.line);
onChange(next);
setRemoved(null);
};
const addUpsell = () => upsell && onChange([...lines, { key: `u${Date.now()}`, product: upsell, frame: upsell.swatches[0].name, size: 'A4', qty: 1 }]);
return (
<div className={cn('absolute inset-0 z-20', !open && 'pointer-events-none')} aria-hidden={!open}>
<div onClick={onClose} className={cn('absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-300', open ? 'opacity-100' : 'opacity-0')} />
<aside
role="dialog"
aria-label="Shopping cart"
className={cn('absolute inset-y-0 right-0 flex w-full max-w-[400px] flex-col bg-ink-900 shadow-pop transition-transform duration-500 ease-smooth', open ? 'translate-x-0' : 'translate-x-full')}
>
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
<h3 className="flex items-center gap-2 text-[15px] font-medium text-ink-50">
Your cart <span className="rounded-full bg-white/5 px-2 font-mono text-[11px] tabular-nums text-ink-300">{count}</span>
</h3>
<button onClick={onClose} aria-label="Close cart" className="focus-ring grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-ink-100">
<X className="h-4 w-4" />
</button>
</header>
<div className="border-b border-white/5 px-5 py-3">
<p className="flex items-center gap-2 text-[12.5px] text-ink-300">
<Truck className={cn('h-4 w-4', left ? 'text-ink-500' : 'text-emerald-400')} />
{left ? <>You're <b className="font-medium text-ink-50">{money(left)}</b> away from free shipping</> : <span className="text-emerald-300">You've unlocked free shipping</span>}
</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/5">
<div className={cn('h-full rounded-full transition-all duration-700 ease-smooth', left ? 'bg-brand-400' : 'bg-emerald-400')} style={{ width: `${Math.min(100, (subtotal / FREE_SHIPPING) * 100)}%` }} />
</div>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto px-3 py-3">
{lines.map((l) => (
<li key={l.key} className="flex gap-3 rounded-xl p-2 transition-colors hover:bg-white/[0.02] animate-fade-in">
<Print art={l.product.art[0]} frame={l.product.swatches.find((s) => s.name === l.frame)?.hex} className="h-20 w-16 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-[13.5px] font-medium text-ink-50">{l.product.name}</p>
<p className="text-[11.5px] text-ink-500">{l.frame} frame · {l.size}</p>
</div>
<p className="shrink-0 text-[13px] tabular-nums text-ink-100">{money(l.product.price * l.qty)}</p>
</div>
<div className="mt-auto flex items-center justify-between pt-2">
<div className="flex items-center rounded-lg bg-white/5 shadow-hairline">
<button aria-label={`Decrease ${l.product.name}`} onClick={() => setQty(l.key, l.qty - 1)} className="focus-ring grid h-7 w-7 place-items-center text-ink-400 hover:text-ink-50"><Minus className="h-3 w-3" /></button>
<span className="w-5 text-center font-mono text-[12px] tabular-nums text-ink-100">{l.qty}</span>
<button aria-label={`Increase ${l.product.name}`} onClick={() => setQty(l.key, l.qty + 1)} className="focus-ring grid h-7 w-7 place-items-center text-ink-400 hover:text-ink-50"><Plus className="h-3 w-3" /></button>
</div>
<button onClick={() => remove(l.key)} aria-label={`Remove ${l.product.name}`} className="focus-ring grid h-7 w-7 place-items-center rounded-lg text-ink-500 hover:bg-white/5 hover:text-rose-300">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</li>
))}
{!lines.length && (
<li className="grid place-items-center py-10 text-center">
<ShoppingBag className="h-8 w-8 text-ink-600" />
<p className="mt-3 text-sm text-ink-300">Your cart is empty</p>
</li>
)}
{removed && (
<li className="flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-[12.5px] text-ink-300 animate-fade-up">
Removed {removed.line.product.name}
<button onClick={undo} className="focus-ring rounded-md px-2 py-0.5 font-medium text-brand-300 hover:bg-brand-500/10">Undo</button>
</li>
)}
</ul>
{upsell && !lines.some((l) => l.product.id === upsell.id) && (
<div className="mx-5 mb-3 flex items-center gap-3 rounded-xl bg-white/[0.03] p-2.5 shadow-hairline">
<Print art={upsell.art[0]} className="h-12 w-10 shrink-0 rounded-md" />
<div className="min-w-0 flex-1">
<p className="text-[11px] text-ink-500">Pairs well with your picks</p>
<p className="truncate text-[13px] text-ink-100">{upsell.name} · {money(upsell.price)}</p>
</div>
<button onClick={addUpsell} className="focus-ring rounded-lg bg-white/10 px-2.5 py-1.5 text-[12px] font-medium text-ink-50 hover:bg-white/15">Add</button>
</div>
)}
<footer className="border-t border-white/5 px-5 py-4">
<div className="flex justify-between text-[13px] text-ink-400"><span>Subtotal</span><span className="tabular-nums text-ink-100">{money(subtotal)}</span></div>
<div className="mt-1 flex justify-between text-[13px] text-ink-400"><span>Shipping</span><span className="text-ink-100">{left ? 'Calculated at checkout' : 'Free'}</span></div>
<button
onClick={onCheckout}
disabled={!lines.length}
className="focus-ring mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-400 text-sm font-medium text-ink-950 transition-all hover:bg-brand-300 active:scale-[0.98] disabled:opacity-40"
>
<Lock className="h-3.5 w-3.5" /> Checkout · {money(subtotal)} <ArrowRight className="h-4 w-4" />
</button>
</footer>
</aside>
</div>
);
}
export function CartDrawerDemo() {
const [open, setOpen] = useState(true);
const [lines, setLines] = useState<CartLine[]>([
{ key: 'a', product: PRODUCTS[0], frame: 'Oak', size: 'A3', qty: 1 },
{ key: 'b', product: PRODUCTS[3], frame: 'Black', size: 'A4', qty: 1 },
]);
const count = lines.reduce((s, l) => s + l.qty, 0);
return (
<div className="relative h-[540px] w-full overflow-hidden rounded-2xl bg-ink-950 shadow-card">
<div className="flex items-center justify-between border-b border-white/5 px-5 py-3.5">
<span className="text-[15px] font-semibold tracking-tight text-ink-50">Fieldhouse</span>
<button onClick={() => setOpen(true)} aria-label="Open cart" className="focus-ring relative grid h-9 w-9 place-items-center rounded-lg text-ink-200 hover:bg-white/5">
<ShoppingBag className="h-4 w-4" />
<span className="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-brand-400 px-1 text-[10px] font-semibold text-ink-950">{count}</span>
</button>
</div>
<div className="grid grid-cols-3 gap-3 p-5 opacity-60">
{PRODUCTS.slice(0, 3).map((p) => (
<Print key={p.id} art={p.art[0]} className="aspect-[4/5] rounded-xl" />
))}
</div>
<CartDrawer open={open} onClose={() => setOpen(false)} lines={lines} onChange={setLines} upsell={PRODUCTS[4]} onCheckout={() => setOpen(false)} />
</div>
);
}components/niceui/commerce/data.ts
import { ARTWORKS, artwork } from '@/components/niceui/gallery/art';
export type Swatch = { name: string; hex: string };
export type Product = {
id: number;
name: string;
kind: string;
price: number;
compareAt?: number;
badge?: 'New' | 'Sale' | 'Limited';
rating: number;
reviews: number;
art: [number, number];
swatches: Swatch[];
sizes: { label: string; stock: number }[];
};
const FRAMES: Swatch[] = [
{ name: 'Oak', hex: '#c8a27a' },
{ name: 'Black', hex: '#1c1c1f' },
{ name: 'White', hex: '#f1efe9' },
];
const SIZES = [
{ label: 'A4', stock: 12 },
{ label: 'A3', stock: 4 },
{ label: 'A2', stock: 0 },
{ label: 'A1', stock: 2 },
];
export const PRODUCTS: Product[] = [
{ id: 0, name: 'Low Tide, Dawn', kind: 'Giclée print', price: 68, badge: 'New', rating: 4.9, reviews: 214, art: [0, 6], swatches: FRAMES, sizes: SIZES },
{ id: 1, name: 'Neon Bloom', kind: 'Framed print', price: 96, compareAt: 128, badge: 'Sale', rating: 4.7, reviews: 98, art: [1, 8], swatches: FRAMES.slice(0, 2), sizes: SIZES },
{ id: 2, name: 'Glass Lagoon', kind: 'Canvas', price: 142, rating: 4.8, reviews: 61, art: [2, 7], swatches: FRAMES, sizes: SIZES },
{ id: 3, name: 'Copper Dunes', kind: 'Giclée print', price: 74, badge: 'Limited', rating: 5, reviews: 33, art: [3, 11], swatches: FRAMES.slice(1), sizes: SIZES },
{ id: 4, name: 'Fog Study No. 4', kind: 'Framed print', price: 88, rating: 4.6, reviews: 120, art: [4, 10], swatches: FRAMES, sizes: SIZES },
{ id: 5, name: 'Green Hour', kind: 'Canvas', price: 118, compareAt: 150, badge: 'Sale', rating: 4.8, reviews: 76, art: [5, 9], swatches: FRAMES.slice(0, 2), sizes: SIZES },
];
export const img = (artId: number, w = 600, h = 750) => artwork(artId, w, h);
export const artistOf = (p: Product) => ARTWORKS[p.art[0]].artist;
export const money = (n: number) => `$${n.toFixed(n % 1 ? 2 : 0)}`;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)})`;
}components/niceui/commerce/Print.tsx
import { cn } from '@/lib/cn';
import { img } from './data';
/** A framed print on a soft studio wall — the product shot used across the shop demos. */
export function Print({ art, frame = '#1c1c1f', className, zoom = 1 }: { art: number; frame?: string; className?: string; zoom?: number }) {
const light = frame === '#f1efe9';
return (
<div className={cn('relative grid place-items-center overflow-hidden bg-gradient-to-b from-[#1a1b1f] to-[#101114]', className)}>
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/40 to-transparent" />
<div
className="relative w-[62%] p-[3.2%] shadow-[0_24px_40px_-16px_rgba(0,0,0,0.8)] transition-transform duration-500 ease-smooth"
style={{ background: frame, transform: `scale(${zoom})` }}
>
<div className={cn('p-[7%]', light ? 'bg-white' : 'bg-[#f6f4ef]')}>
<img src={img(art)} alt="" draggable={false} className="block aspect-[4/5] w-full object-cover" />
</div>
</div>
</div>
);
}