Checkout New
A one-page checkout with contact, shipping, delivery options, card fields that format and detect the card type, and an order summary with promo codes.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/commerce-checkout.json02
Or copy the source below (4 files). Dependencies: lucide-react.
Source
components/niceui/commerce/Checkout.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Check, CreditCard, LoaderCircle, Lock, Tag, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { PRODUCTS, money } from './data';
import { Print } from './Print';
type Brand = 'visa' | 'mastercard' | 'amex' | 'discover' | null;
export function detectBrand(num: string): Brand {
const n = num.replace(/\D/g, '');
if (/^4/.test(n)) return 'visa';
if (/^(5[1-5]|2[2-7])/.test(n)) return 'mastercard';
if (/^3[47]/.test(n)) return 'amex';
if (/^6(011|5)/.test(n)) return 'discover';
return null;
}
export function formatCard(v: string) {
const n = v.replace(/\D/g, '');
if (detectBrand(n) === 'amex') return n.slice(0, 15).replace(/^(\d{0,4})(\d{0,6})(\d{0,5}).*/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));
return n.slice(0, 16).replace(/(\d{4})(?=\d)/g, '$1 ');
}
const BRAND_STYLE: Record<Exclude<Brand, null>, { label: string; bg: string }> = {
visa: { label: 'VISA', bg: 'bg-[#1a1f71]' },
mastercard: { label: 'MC', bg: 'bg-[#2b2b2b]' },
amex: { label: 'AMEX', bg: 'bg-[#2e77bc]' },
discover: { label: 'DISC', bg: 'bg-[#e0761a]' },
};
/** Generic card-network badge (text mark, no network logos). */
function BrandBadge({ brand }: { brand: Brand }) {
if (!brand) return <CreditCard className="h-4 w-4 text-ink-500" />;
const s = BRAND_STYLE[brand];
return <span className={cn('rounded px-1.5 py-0.5 font-mono text-[9px] font-semibold tracking-wider text-white animate-scale-in', s.bg)}>{s.label}</span>;
}
function Field({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<label className={cn('block min-w-0', className)}>
<span className="mb-1.5 block text-[11.5px] text-ink-400">{label}</span>
{children}
</label>
);
}
const input = 'focus-ring h-10 w-full rounded-lg border border-white/10 bg-ink-950/60 px-3 text-[13.5px] text-ink-50 outline-none transition-colors placeholder:text-ink-600 focus:border-brand-400/60';
const DELIVERY = [
{ id: 'standard', label: 'Standard', eta: '3–5 business days', price: 0 },
{ id: 'express', label: 'Express', eta: '1–2 business days', price: 18 },
{ id: 'white', label: 'White glove', eta: 'Delivered and hung', price: 65 },
];
const ITEMS = [
{ p: PRODUCTS[0], qty: 1, frame: 'Oak', size: 'A3' },
{ p: PRODUCTS[2], qty: 1, frame: 'Black', size: 'A2' },
];
export function Checkout({ className }: { className?: string }) {
const [email, setEmail] = useState('maya@halcyon.studio');
const [card, setCard] = useState('4242 4242 4242');
const [exp, setExp] = useState('');
const [cvc, setCvc] = useState('');
const [delivery, setDelivery] = useState('express');
const [promo, setPromo] = useState('');
const [applied, setApplied] = useState<string | null>(null);
const [promoError, setPromoError] = useState(false);
const [state, setState] = useState<'idle' | 'paying' | 'paid'>('idle');
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
const brand = detectBrand(card);
const subtotal = ITEMS.reduce((s, i) => s + i.p.price * i.qty, 0);
const discount = applied ? Math.round(subtotal * 0.15) : 0;
const ship = DELIVERY.find((d) => d.id === delivery)!.price;
const tax = Math.round((subtotal - discount) * 0.08);
const total = subtotal - discount + ship + tax;
const cardDigits = card.replace(/\D/g, '').length;
const valid = /\S+@\S+\.\S+/.test(email) && cardDigits >= (brand === 'amex' ? 15 : 16) && exp.length === 5 && cvc.length >= 3;
const applyPromo = () => {
if (promo.trim().toUpperCase() === 'FIELD15') {
setApplied('FIELD15');
setPromoError(false);
setPromo('');
} else setPromoError(true);
};
const pay = () => {
if (!valid || state !== 'idle') return;
setState('paying');
timer.current = window.setTimeout(() => setState('paid'), 1400);
};
if (state === 'paid')
return (
<div className={cn('grid w-full place-items-center rounded-2xl bg-ink-900 p-10 text-center shadow-card', className)}>
<span className="grid h-14 w-14 place-items-center rounded-full bg-emerald-500/15 text-emerald-300 animate-scale-in">
<Check className="h-7 w-7" />
</span>
<h3 className="mt-4 text-xl font-semibold text-ink-50">Order confirmed</h3>
<p className="mt-1 text-sm text-ink-400">#FH-20481 · {money(total)} · receipt sent to {email}</p>
<button onClick={() => setState('idle')} className="focus-ring mt-5 rounded-lg bg-white/5 px-3 py-1.5 text-[12.5px] text-ink-200 hover:bg-white/10">Back to checkout</button>
</div>
);
return (
<div className={cn('grid w-full overflow-hidden rounded-2xl bg-ink-900 shadow-card md:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]', className)}>
<form className="min-w-0 space-y-6 p-5 sm:p-6" onSubmit={(e) => (e.preventDefault(), pay())}>
<section>
<h4 className="text-[13px] font-medium text-ink-100">Contact</h4>
<Field label="Email" className="mt-3">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className={input} autoComplete="email" />
</Field>
</section>
<section>
<h4 className="text-[13px] font-medium text-ink-100">Shipping address</h4>
<div className="mt-3 grid grid-cols-2 gap-3">
<Field label="First name"><input defaultValue="Maya" className={input} /></Field>
<Field label="Last name"><input defaultValue="Ocampo" className={input} /></Field>
<Field label="Address" className="col-span-2"><input defaultValue="18 Rua das Flores" className={input} /></Field>
<Field label="City"><input defaultValue="Lisbon" className={input} /></Field>
<Field label="Postcode"><input defaultValue="1200-195" className={input} /></Field>
</div>
</section>
<section>
<h4 className="text-[13px] font-medium text-ink-100">Delivery</h4>
<div className="mt-3 space-y-2" role="radiogroup" aria-label="Delivery method">
{DELIVERY.map((d) => (
<button
type="button"
key={d.id}
role="radio"
aria-checked={delivery === d.id}
onClick={() => setDelivery(d.id)}
className={cn('focus-ring flex w-full items-center gap-3 rounded-xl border px-3.5 py-3 text-left transition-colors', delivery === d.id ? 'border-brand-400/60 bg-brand-500/[0.07]' : 'border-white/10 hover:border-white/20')}
>
<span className={cn('grid h-4 w-4 shrink-0 place-items-center rounded-full border', delivery === d.id ? 'border-brand-300' : 'border-ink-500')}>
{delivery === d.id && <span className="h-2 w-2 rounded-full bg-brand-300 animate-scale-in" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] text-ink-50">{d.label}</span>
<span className="block text-[11.5px] text-ink-500">{d.eta}</span>
</span>
<span className="text-[13px] tabular-nums text-ink-200">{d.price ? money(d.price) : 'Free'}</span>
</button>
))}
</div>
</section>
<section>
<h4 className="flex items-center gap-2 text-[13px] font-medium text-ink-100">Payment <Lock className="h-3 w-3 text-ink-500" /></h4>
<div className="mt-3 grid grid-cols-2 gap-3">
<Field label="Card number" className="col-span-2">
<div className="relative">
<input
inputMode="numeric"
autoComplete="cc-number"
value={card}
onChange={(e) => setCard(formatCard(e.target.value))}
placeholder="1234 1234 1234 1234"
className={cn(input, 'pr-16 font-mono tracking-wide')}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2"><BrandBadge brand={brand} /></span>
</div>
</Field>
<Field label="Expiry">
<input
inputMode="numeric"
autoComplete="cc-exp"
placeholder="MM/YY"
value={exp}
onChange={(e) => {
const n = e.target.value.replace(/\D/g, '').slice(0, 4);
setExp(n.length > 2 ? `${n.slice(0, 2)}/${n.slice(2)}` : n);
}}
className={cn(input, 'font-mono')}
/>
</Field>
<Field label="CVC">
<input inputMode="numeric" autoComplete="cc-csc" placeholder={brand === 'amex' ? '4 digits' : '3 digits'} value={cvc} onChange={(e) => setCvc(e.target.value.replace(/\D/g, '').slice(0, brand === 'amex' ? 4 : 3))} className={cn(input, 'font-mono')} />
</Field>
</div>
<p className="mt-2 text-[11.5px] text-ink-500">Try 4…, 5…, 34… or 6011… to see the card type change.</p>
</section>
<button
type="submit"
disabled={!valid}
className="focus-ring flex h-12 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:cursor-not-allowed disabled:opacity-40"
>
{state === 'paying' ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <><Lock className="h-3.5 w-3.5" /> Pay {money(total)}</>}
</button>
</form>
<aside className="min-w-0 border-t border-white/5 bg-white/[0.02] p-5 sm:p-6 md:border-l md:border-t-0">
<h4 className="text-[13px] font-medium text-ink-100">Order summary</h4>
<ul className="mt-4 space-y-3">
{ITEMS.map(({ p, qty, frame, size }) => (
<li key={p.id} className="flex items-center gap-3">
<div className="relative">
<Print art={p.art[0]} className="h-16 w-[52px] rounded-lg" />
<span className="absolute -right-1.5 -top-1.5 grid h-5 w-5 place-items-center rounded-full bg-ink-700 text-[10px] font-medium text-ink-100">{qty}</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] text-ink-50">{p.name}</p>
<p className="text-[11.5px] text-ink-500">{frame} · {size}</p>
</div>
<span className="text-[13px] tabular-nums text-ink-200">{money(p.price * qty)}</span>
</li>
))}
</ul>
<div className="mt-5">
{applied ? (
<div className="flex items-center justify-between rounded-lg bg-emerald-500/10 px-3 py-2 text-[12.5px] text-emerald-300 animate-fade-in">
<span className="flex items-center gap-1.5"><Tag className="h-3.5 w-3.5" /> {applied} · 15% off</span>
<button onClick={() => setApplied(null)} aria-label="Remove code" className="focus-ring rounded p-0.5 hover:bg-emerald-500/10"><X className="h-3.5 w-3.5" /></button>
</div>
) : (
<div>
<div className="flex gap-2">
<input value={promo} onChange={(e) => (setPromo(e.target.value), setPromoError(false))} onKeyDown={(e) => e.key === 'Enter' && applyPromo()} placeholder="Gift card or code" aria-label="Promo code" className={cn(input, 'uppercase', promoError && 'border-rose-400/60')} />
<button onClick={applyPromo} className="focus-ring shrink-0 rounded-lg bg-white/10 px-3 text-[12.5px] font-medium text-ink-50 hover:bg-white/15">Apply</button>
</div>
<p className={cn('mt-1.5 text-[11.5px]', promoError ? 'text-rose-300' : 'text-ink-500')}>{promoError ? 'That code isn’t valid.' : 'Try FIELD15'}</p>
</div>
)}
</div>
<dl className="mt-5 space-y-2 border-t border-white/5 pt-4 text-[13px]">
<div className="flex justify-between text-ink-400"><dt>Subtotal</dt><dd className="tabular-nums text-ink-100">{money(subtotal)}</dd></div>
{discount > 0 && <div className="flex justify-between text-emerald-300"><dt>Discount</dt><dd className="tabular-nums">−{money(discount)}</dd></div>}
<div className="flex justify-between text-ink-400"><dt>Shipping</dt><dd className="tabular-nums text-ink-100">{ship ? money(ship) : 'Free'}</dd></div>
<div className="flex justify-between text-ink-400"><dt>Tax</dt><dd className="tabular-nums text-ink-100">{money(tax)}</dd></div>
<div className="flex justify-between border-t border-white/5 pt-3 text-[15px] font-medium text-ink-50"><dt>Total</dt><dd className="tabular-nums">{money(total)}</dd></div>
</dl>
</aside>
</div>
);
}
export function CheckoutDemo() {
return (
<div className="w-full max-w-4xl">
<Checkout />
</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>
);
}