Commerce Dashboard
Store admin with sales KPIs, an orders table, best sellers, an animated conversion funnel, customer reviews and inventory alerts.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/examples-ecommerce.json02
Or copy the source below (4 files). Dependencies: lucide-react.
Source
components/niceui/pages/examples/CommerceDashboard.tsx
import { ArrowDownRight, ArrowUpRight, Bell, DollarSign, Download, MoreHorizontal, Package, ShoppingBag, Star, TrendingUp } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/cn';
import { CountUp } from './shared/charts';
import { AppShell, Avatar, Badge, Card, Delta, ProgressBar, SectionTitle, TopBar, avatarColors, type NavItem } from './shared/shell';
const nav: NavItem[] = [
{ id: 'commerce', label: 'Overview', icon: ShoppingBag },
{ id: 'orders', label: 'Orders', icon: Package, badge: 28 },
{ id: 'products', label: 'Products', icon: Package },
{ id: 'customers', label: 'Customers', icon: Star },
{ id: 'reports', label: 'Reports', icon: TrendingUp },
{ id: 'settings', label: 'Settings', icon: Bell },
];
const kpis = [
{ label: 'Revenue', value: 142800, prefix: '$', delta: 18.2, up: true },
{ label: 'Orders', value: 3284, delta: 9.4, up: true },
{ label: 'Avg. order', value: 43.5, prefix: '$', decimals: 2, delta: -2.1, up: false },
{ label: 'Conversion', value: 3.2, suffix: '%', decimals: 2, delta: 0.8, up: true },
];
const orders = [
{ id: '#10421', customer: 'Mira Chen', email: 'mira@acme.co', product: 'Aurora Headset', amount: 289, status: 'paid', color: 'azure' as const, date: 'Mar 30' },
{ id: '#10420', customer: 'Joon Sohn', email: 'joon@skyline.io', product: 'Nimbus Keyboard', amount: 159, status: 'shipped', color: 'emerald' as const, date: 'Mar 30' },
{ id: '#10419', customer: 'Lina Vega', email: 'lina@vega.studio', product: 'Orbit Mouse', amount: 79, status: 'pending', color: 'amber' as const, date: 'Mar 29' },
{ id: '#10418', customer: 'Ravi Desai', email: 'ravi@desai.dev', product: 'Aurora Headset', amount: 289, status: 'refunded', color: 'rose' as const, date: 'Mar 29' },
{ id: '#10417', customer: 'Maya Klein', email: 'maya@klein.design', product: 'Pulse Speaker', amount: 199, status: 'paid', color: 'sky' as const, date: 'Mar 28' },
];
const bestSellers = [
{ name: 'Aurora Headset', sku: 'AUR-001', sold: 1240, revenue: 358360, grad: 'from-brand-400 via-brand-600 to-brand-900' },
{ name: 'Nimbus Keyboard', sku: 'NIM-014', sold: 980, revenue: 155820, grad: 'from-emerald-300 via-teal-500 to-sky-700' },
{ name: 'Orbit Mouse', sku: 'ORB-008', sold: 842, revenue: 66518, grad: 'from-amber-400 via-orange-500 to-rose-600' },
{ name: 'Pulse Speaker', sku: 'PLS-022', sold: 514, revenue: 102286, grad: 'from-sky-400 via-sky-600 to-brand-900' },
];
const funnel = [
{ stage: 'Visitors', value: 84200, pct: 100, color: '#38bdf8' },
{ stage: 'Product views', value: 41280, pct: 49, color: '#7dd3fc' },
{ stage: 'Add to cart', value: 9840, pct: 11.7, color: '#bae6fd' },
{ stage: 'Checkout', value: 4120, pct: 4.9, color: '#34d399' },
{ stage: 'Purchase', value: 3284, pct: 3.9, color: '#fbbf24' },
];
const reviews = [
{ who: 'Mira Chen', color: 'azure' as const, text: 'Best headset I have owned — sound is incredible.', stars: 5 },
{ who: 'Joon Sohn', color: 'emerald' as const, text: 'Keyboard feels premium, battery lasts forever.', stars: 5 },
{ who: 'Lina Vega', color: 'rose' as const, text: 'Great mouse, wish the cable was longer.', stars: 4 },
];
const inventory = [
{ name: 'Aurora Headset', stock: 240, level: 'ok' as const },
{ name: 'Nimbus Keyboard', stock: 38, level: 'low' as const },
{ name: 'Orbit Mouse', stock: 12, level: 'critical' as const },
{ name: 'Pulse Speaker', stock: 186, level: 'ok' as const },
];
const statusTone = { paid: 'success', shipped: 'sky', pending: 'warning', refunded: 'error' } as const;
export function CommerceDashboard() {
const [hover, setHover] = useState<number | null>(null);
const [active, setActive] = useState('commerce');
return (
<AppShell
brand={<><span className="grid h-8 w-8 place-items-center rounded-lg bg-amber-500/15 text-amber-300"><ShoppingBag className="h-4 w-4" /></span><span className="text-sm font-semibold text-ink-50">Shopify</span></>}
nav={nav}
active={active}
onNav={setActive}
topbar={<TopBar dateLabel="Mar 24 – Mar 30, 2026" notif={4} initials="AR" color="amber" />}
>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{kpis.map((k, i) => (
<Card key={k.label} delay={i * 60}>
<span className="text-xs font-medium text-ink-400">{k.label}</span>
<div className="mt-2 text-2xl font-semibold text-ink-50"><CountUp value={k.value} prefix={k.prefix ?? ''} suffix={k.suffix ?? ''} decimals={k.decimals ?? 0} /></div>
<div className="mt-1.5 flex items-center gap-1 text-xs"><span className={cn('inline-flex items-center gap-0.5', k.up ? 'text-emerald-400' : 'text-rose-400')}>{k.up ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}{Math.abs(k.delta)}%</span><span className="text-ink-500">vs last week</span></div>
</Card>
))}
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2" delay={260}>
<SectionTitle title="Recent orders" action={<button className="flex items-center gap-1.5 text-xs text-brand-300 hover:text-brand-200"><Download className="h-3.5 w-3.5" />Export</button>} />
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-left text-sm">
<thead><tr className="text-xs text-ink-500"><th className="pb-2 font-medium">Order</th><th className="pb-2 font-medium">Customer</th><th className="pb-2 font-medium">Product</th><th className="pb-2 font-medium">Status</th><th className="pb-2 text-right font-medium">Total</th><th className="pb-2"></th></tr></thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="group border-t border-white/5 transition-colors hover:bg-white/[0.03]">
<td className="py-3 font-mono text-[13px] text-brand-300">{o.id}</td>
<td className="py-3"><div className="flex items-center gap-2.5"><Avatar initials={o.customer.split(' ').map((w) => w[0]).join('')} color={o.color} size="sm" /><div><div className="text-ink-100">{o.customer}</div><div className="text-xs text-ink-500">{o.email}</div></div></div></td>
<td className="py-3 text-ink-300">{o.product}</td>
<td className="py-3"><Badge tone={statusTone[o.status as 'paid']}>{o.status}</Badge></td>
<td className="py-3 text-right font-medium text-ink-100">${o.amount}</td>
<td className="py-3 text-right"><button className="opacity-0 transition-opacity group-hover:opacity-100"><MoreHorizontal className="h-4 w-4 text-ink-500 hover:text-ink-100" /></button></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card delay={320}>
<SectionTitle title="Best sellers" sub="This month" />
<div className="space-y-2">
{bestSellers.map((b, i) => (
<div key={b.sku} className="flex items-center gap-3 rounded-xl p-2 transition-colors hover:bg-white/[0.02] animate-fade-up" style={{ animationDelay: `${320 + i * 60}ms` }}>
<span className={cn('grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-gradient-to-br text-white', b.grad)}><Package className="h-5 w-5" /></span>
<div className="min-w-0 flex-1"><div className="truncate text-sm text-ink-100">{b.name}</div><div className="text-xs text-ink-500">{b.sold} sold</div></div>
<span className="text-sm font-medium text-ink-100">${(b.revenue / 1000).toFixed(1)}k</span>
</div>
))}
</div>
</Card>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card delay={420}>
<SectionTitle title="Conversion funnel" sub="Sessions → purchase" />
<div className="space-y-2.5">
{funnel.map((f, i) => (
<div key={f.stage} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} className="animate-fade-up" style={{ animationDelay: `${420 + i * 70}ms` }}>
<div className="flex items-center justify-between text-xs"><span className="text-ink-300">{f.stage}</span><span className="text-ink-500">{f.value.toLocaleString()} · {f.pct}%</span></div>
<div className="mt-1 h-7 w-full overflow-hidden rounded-lg bg-white/5">
<div className="flex h-full items-center rounded-lg px-2 transition-all duration-700 ease-smooth" style={{ width: hover === i ? `${Math.max(f.pct, 12)}%` : `${f.pct}%`, background: f.color, transitionDelay: `${i * 80}ms` }}>
<span className="text-[10px] font-medium text-ink-950/70">{f.pct}%</span>
</div>
</div>
</div>
))}
</div>
</Card>
<Card delay={480}>
<SectionTitle title="Customer reviews" sub="Last 7 days" />
<div className="space-y-3">
{reviews.map((r, i) => (
<div key={i} className="rounded-xl bg-ink-850 p-3 animate-fade-up" style={{ animationDelay: `${480 + i * 70}ms` }}>
<div className="flex items-center gap-2.5"><Avatar initials={r.who.split(' ').map((w) => w[0]).join('')} color={r.color} size="sm" /><span className="text-sm text-ink-100">{r.who}</span><span className="ml-auto flex gap-0.5">{Array.from({ length: 5 }, (_, s) => <Star key={s} className={cn('h-3 w-3', s < r.stars ? 'fill-amber-400 text-amber-400' : 'text-ink-700')} />)}</span></div>
<p className="mt-2 text-xs text-ink-400">{r.text}</p>
</div>
))}
</div>
</Card>
<Card delay={540}>
<SectionTitle title="Inventory alerts" sub="Low stock items" />
<div className="space-y-2">
{inventory.map((it, i) => (
<div key={it.name} className="flex items-center gap-3 rounded-xl bg-ink-850 p-3 animate-fade-up" style={{ animationDelay: `${540 + i * 60}ms` }}>
<span className={cn('grid h-9 w-9 place-items-center rounded-lg', it.level === 'ok' ? 'bg-emerald-500/10 text-emerald-300' : it.level === 'low' ? 'bg-amber-500/10 text-amber-300' : 'bg-rose-500/10 text-rose-300')}><Package className="h-4 w-4" /></span>
<div className="min-w-0 flex-1"><div className="truncate text-sm text-ink-100">{it.name}</div><div className="mt-1"><ProgressBar value={Math.min(it.stock, 240) / 240 * 100} color={it.level === 'ok' ? '#34d399' : it.level === 'low' ? '#fbbf24' : '#fb7185'} /></div></div>
<span className="text-sm font-medium text-ink-100">{it.stock}</span>
</div>
))}
</div>
</Card>
</div>
</AppShell>
);
}components/niceui/pages/examples/shared/charts.tsx
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
import { easeOutExpo, useCountUp, useSize } from './hooks';
/* ----------------------------- count-up number ---------------------------- */
export function CountUp({
value,
prefix = '',
suffix = '',
decimals = 0,
duration = 900,
className,
}: {
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
duration?: number;
className?: string;
}) {
const d = useCountUp(value, duration);
return (
<span className={className}>
{prefix}
{d.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}
{suffix}
</span>
);
}
/* -------------------------------- sparkline ------------------------------- */
export function Sparkline({
data,
color = '#38bdf8',
fill = true,
width = 120,
height = 36,
className,
}: {
data: number[];
color?: string;
fill?: boolean;
width?: number;
height?: number;
className?: string;
}) {
const raw = useId().replace(/:/g, '');
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const pts = data.map((v, i) => [
(i / (data.length - 1)) * width,
height - 3 - ((v - min) / range) * (height - 6),
]);
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
const [drawn, setDrawn] = useState(false);
useEffect(() => {
setDrawn(false);
const t = requestAnimationFrame(() => requestAnimationFrame(() => setDrawn(true)));
return () => cancelAnimationFrame(t);
}, [data]);
const ref = useRef<SVGPathElement>(null);
const [len, setLen] = useState(0);
useLayoutEffect(() => {
if (ref.current) setLen(ref.current.getTotalLength());
}, [data, width]);
return (
<svg className={className} width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none">
{fill && (
<>
<defs>
<linearGradient id={`sp-${raw}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={`${line} L ${width} ${height} L 0 ${height} Z`} fill={`url(#sp-${raw})`} />
</>
)}
<path
ref={ref}
d={line}
stroke={color}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={len}
strokeDashoffset={drawn ? 0 : len}
style={{ transition: 'stroke-dashoffset 900ms cubic-bezier(0.16,1,0.3,1)' }}
/>
</svg>
);
}
/* -------------------------------- area chart ------------------------------ */
export function AreaChart({
data,
height = 240,
color = '#38bdf8',
format = (v) => String(v),
}: {
data: { label: string; value: number }[];
height?: number;
color?: string;
format?: (v: number) => string;
}) {
const [ref, w] = useSize<HTMLDivElement>();
const raw = useId().replace(/:/g, '');
const width = w || 640;
const pad = { l: 6, r: 6, t: 14, b: 22 };
const iw = Math.max(1, width - pad.l - pad.r);
const ih = height - pad.t - pad.b;
const vals = data.map((d) => d.value);
const min = Math.min(...vals);
const max = Math.max(...vals);
const range = max - min || 1;
const pts = data.map((d, i) => ({
x: pad.l + (i / (data.length - 1)) * iw,
y: pad.t + ih - ((d.value - min) / range) * (ih - 8) - 4,
...d,
}));
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
const area = `${line} L ${pts[pts.length - 1].x.toFixed(1)} ${(pad.t + ih).toFixed(1)} L ${pts[0].x.toFixed(1)} ${(pad.t + ih).toFixed(1)} Z`;
const [hover, setHover] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [len, setLen] = useState(0);
const [drawn, setDrawn] = useState(false);
const pathRef = useRef<SVGPathElement>(null);
useLayoutEffect(() => {
if (pathRef.current) setLen(pathRef.current.getTotalLength());
}, [data, width]);
useEffect(() => {
setDrawn(false);
const t = requestAnimationFrame(() => requestAnimationFrame(() => setDrawn(true)));
return () => cancelAnimationFrame(t);
}, [data, width]);
const gridY = [0, 0.25, 0.5, 0.75, 1];
const move = (e: React.MouseEvent) => {
const el = svgRef.current;
if (!el) return;
const x = e.clientX - el.getBoundingClientRect().left - pad.l;
setHover(Math.max(0, Math.min(data.length - 1, Math.round((x / iw) * (data.length - 1)))));
};
const hp = hover != null ? pts[hover] : null;
return (
<div ref={ref} className="relative" onMouseMove={move} onMouseLeave={() => setHover(null)}>
<svg ref={svgRef} width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none">
<defs>
<linearGradient id={`ar-${raw}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.32" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
{gridY.map((g) => (
<line key={g} x1={pad.l} x2={width - pad.r} y1={pad.t + g * ih} y2={pad.t + g * ih} stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
))}
<path d={area} fill={`url(#ar-${raw})`} />
<path
ref={pathRef}
d={line}
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={len}
strokeDashoffset={drawn ? 0 : len}
style={{ transition: 'stroke-dashoffset 1000ms cubic-bezier(0.16,1,0.3,1)' }}
/>
{hp && (
<>
<line x1={hp.x} x2={hp.x} y1={pad.t} y2={pad.t + ih} stroke="rgba(255,255,255,0.18)" strokeWidth="1" strokeDasharray="3 3" />
<circle cx={hp.x} cy={hp.y} r="4.5" fill={color} stroke="#0a0a0c" strokeWidth="2" />
</>
)}
{pts.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map((p) => (
<text key={p.x} x={p.x} y={height - 6} textAnchor="middle" className="fill-ink-500" style={{ fontSize: 10 }}>
{p.label}
</text>
))}
</svg>
{hp && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 rounded-lg bg-ink-800 px-2.5 py-1.5 text-xs shadow-pop ring-1 ring-white/10"
style={{ left: Math.max(56, Math.min(width - 56, hp.x)), top: Math.max(0, hp.y - 48) }}
>
<div className="font-medium text-ink-50">{format(hp.value)}</div>
<div className="text-[10px] text-ink-400">{hp.label}</div>
</div>
)}
</div>
);
}
/* -------------------------------- bar chart ------------------------------- */
export function BarChart({
series,
groups,
height = 220,
format = (v) => String(v),
}: {
series: { key: string; label: string; color: string }[];
groups: { label: string; values: Record<string, number> }[];
height?: number;
format?: (v: number) => string;
}) {
const max = Math.max(1, ...groups.flatMap((g) => series.map((s) => g.values[s.key] ?? 0)));
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(false);
const t = requestAnimationFrame(() => requestAnimationFrame(() => setMounted(true)));
return () => cancelAnimationFrame(t);
}, [groups]);
const [hover, setHover] = useState<number | null>(null);
const ih = height - 28;
return (
<div>
<div className="relative" style={{ height }}>
<div className="absolute inset-x-0 top-0 bottom-7 flex flex-col justify-between">
{[1, 0.66, 0.33, 0].map((g) => (
<div key={g} className="border-t border-white/5" />
))}
</div>
<div className="absolute inset-x-0 top-0 flex items-end justify-around gap-2 px-1" style={{ height: ih }} onMouseLeave={() => setHover(null)}>
{groups.map((g, gi) => (
<div key={g.label} className="group flex h-full flex-1 items-end justify-center gap-1" onMouseEnter={() => setHover(gi)}>
{series.map((s) => {
const h = ((g.values[s.key] ?? 0) / max) * ih;
return (
<div
key={s.key}
className={cn('w-full max-w-[18px] rounded-t-md transition-all duration-700 ease-smooth', hover === gi ? 'opacity-100' : 'opacity-80')}
style={{ height: mounted ? h : 0, background: `linear-gradient(to top, ${s.color}, ${s.color}cc)`, transitionDelay: `${gi * 45}ms` }}
/>
);
})}
</div>
))}
</div>
<div className="absolute inset-x-0 bottom-0 flex justify-around px-1">
{groups.map((g) => (
<span key={g.label} className="flex-1 text-center text-[10px] text-ink-500">{g.label}</span>
))}
</div>
</div>
{hover != null && (
<div className="mt-1 text-xs text-ink-400">
{groups[hover].label}: {series.map((s) => `${s.label} ${format(groups[hover].values[s.key] ?? 0)}`).join(' · ')}
</div>
)}
<div className="mt-2 flex items-center gap-4">
{series.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-xs text-ink-400">
<span className="h-2 w-2 rounded-sm" style={{ background: s.color }} /> {s.label}
</span>
))}
</div>
</div>
);
}
/* ------------------------------- donut chart ------------------------------ */
export function DonutChart({
data,
size = 168,
thickness = 18,
center,
}: {
data: { label: string; value: number; color: string }[];
size?: number;
thickness?: number;
center?: React.ReactNode;
}) {
const total = data.reduce((s, d) => s + d.value, 0) || 1;
const r = (size - thickness) / 2;
const circ = 2 * Math.PI * r;
const [mounted, setMounted] = useState(false);
useEffect(() => {
const t = requestAnimationFrame(() => requestAnimationFrame(() => setMounted(true)));
return () => cancelAnimationFrame(t);
}, []);
let acc = 0;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth={thickness} />
{data.map((d, i) => {
const frac = d.value / total;
const seg = frac * circ;
const offset = -acc * circ;
acc += frac;
return (
<circle
key={d.label}
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={d.color}
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={`${mounted ? seg : 0} ${circ}`}
strokeDashoffset={offset}
style={{ transition: `stroke-dasharray 900ms cubic-bezier(0.16,1,0.3,1) ${i * 110}ms` }}
/>
);
})}
</svg>
{center && <div className="absolute inset-0 flex flex-col items-center justify-center text-center">{center}</div>}
</div>
);
}components/niceui/pages/examples/shared/hooks.ts
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
/** Observe an element's width for responsive SVG charts. */
export function useSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width));
ro.observe(el);
setWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
/** Animate a number from its previous value to the next with rAF + easeOutExpo. */
export function useCountUp(value: number, duration = 900) {
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
const rafRef = useRef<number | undefined>(undefined);
useEffect(() => {
const from = fromRef.current;
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const next = from + (value - from) * easeOutExpo(t);
setDisplay(next);
fromRef.current = next;
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
fromRef.current = value;
};
}, [value, duration]);
return display;
}
/** setInterval with a ref'd callback; pass null to pause. */
export function useInterval(callback: () => void, ms: number | null) {
const saved = useRef(callback);
useEffect(() => {
saved.current = callback;
}, [callback]);
useEffect(() => {
if (ms === null) return;
const id = setInterval(() => saved.current(), ms);
return () => clearInterval(id);
}, [ms]);
}components/niceui/pages/examples/shared/shell.tsx
import { useEffect, useState, type ComponentType, type ReactNode } from 'react';
import { Bell, Search } from 'lucide-react';
import { cn } from '@/lib/cn';
export type NavItem = { id: string; label: string; icon: ComponentType<{ className?: string }>; badge?: number };
export const avatarColors: Record<string, string> = {
azure: 'from-brand-400 to-brand-700',
emerald: 'from-emerald-400 to-teal-600',
amber: 'from-amber-400 to-orange-600',
rose: 'from-rose-400 to-rose-700',
sky: 'from-sky-400 to-sky-700',
teal: 'from-teal-400 to-emerald-600',
};
export function Avatar({ initials, color = 'azure', size = 'md', ring }: { initials: string; color?: keyof typeof avatarColors | string; size?: 'sm' | 'md' | 'lg'; ring?: boolean }) {
const s = size === 'lg' ? 'h-11 w-11 text-base' : size === 'sm' ? 'h-7 w-7 text-[11px]' : 'h-9 w-9 text-xs';
return (
<span
className={cn('inline-flex shrink-0 items-center justify-center rounded-full bg-gradient-to-br font-semibold text-white', avatarColors[color] ?? avatarColors.azure, s, ring && 'ring-2 ring-ink-950')}
>
{initials}
</span>
);
}
export function AppShell({
brand,
nav,
active,
onNav,
topbar,
children,
tools,
}: {
brand: ReactNode;
nav: NavItem[];
active: string;
onNav: (id: string) => void;
topbar: ReactNode;
tools?: ReactNode;
children: ReactNode;
}) {
return (
<div className="flex min-h-full">
{/* sidebar */}
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col border-r border-white/5 bg-ink-950/60 p-3 backdrop-blur lg:flex">
<div className="flex h-12 items-center gap-2.5 px-2">{brand}</div>
<div className="mt-2 flex items-center gap-2 rounded-xl bg-ink-900 px-3 py-2 text-ink-500 ring-1 ring-white/5">
<Search className="h-4 w-4" />
<span className="text-xs">Search…</span>
<kbd className="ml-auto rounded bg-white/10 px-1.5 py-px font-mono text-[10px] text-ink-400">⌘K</kbd>
</div>
<nav className="mt-3 flex-1 space-y-0.5 overflow-y-auto">
{nav.map((n) => {
const on = n.id === active;
const Icon = n.icon;
return (
<button
key={n.id}
onClick={() => onNav(n.id)}
className={cn(
'focus-ring group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
on ? 'bg-white/[0.06] font-medium text-ink-50' : 'text-ink-400 hover:bg-white/[0.03] hover:text-ink-100',
)}
>
<Icon className={cn('h-4 w-4 shrink-0 transition-colors', on ? 'text-brand-400' : 'text-ink-500 group-hover:text-ink-300')} />
{n.label}
{n.badge ? <span className="ml-auto rounded-full bg-brand-500/15 px-1.5 text-[10px] font-semibold text-brand-300">{n.badge}</span> : null}
{on && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-brand-400" />}
</button>
);
})}
</nav>
{tools}
</aside>
{/* mobile top nav scroll */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-white/5 bg-ink-950/80 px-4 backdrop-blur-xl sm:px-6">
<div className="flex items-center gap-2 lg:hidden">{brand}</div>
<div className="relative hidden flex-1 sm:block">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-500" />
<input
placeholder="Search…"
className="focus-ring h-9 w-full max-w-xs rounded-lg bg-ink-900 pl-9 pr-3 text-sm text-ink-100 placeholder:text-ink-500 ring-1 ring-white/5 transition focus:ring-brand-500/40"
/>
</div>
<div className="ml-auto flex items-center gap-2">{topbar}</div>
</header>
{/* mobile icon rail */}
<div className="flex gap-1 overflow-x-auto border-b border-white/5 bg-ink-950/60 px-3 py-2 lg:hidden">
{nav.map((n) => {
const Icon = n.icon;
const on = n.id === active;
return (
<button
key={n.id}
onClick={() => onNav(n.id)}
className={cn('flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition-colors', on ? 'bg-white/[0.06] text-ink-50' : 'text-ink-400')}
>
<Icon className="h-3.5 w-3.5" />
{n.label}
</button>
);
})}
</div>
<main className="min-w-0 flex-1 p-4 sm:p-6">{children}</main>
</div>
</div>
);
}
export function TopBar({ dateLabel, onDate, notif = 3, initials = 'MK', color = 'azure' }: { dateLabel: string; onDate?: () => void; notif?: number; initials?: string; color?: keyof typeof avatarColors | string }) {
return (
<>
<button onClick={onDate} className="focus-ring hidden h-9 items-center gap-2 rounded-lg bg-ink-900 px-3 text-sm text-ink-300 ring-1 ring-white/5 transition hover:bg-ink-850 hover:text-ink-50 active:scale-95 sm:flex">
<span className="text-ink-500">Range</span>
<span className="font-medium text-ink-100">{dateLabel}</span>
</button>
<button className="focus-ring relative flex h-9 w-9 items-center justify-center rounded-lg text-ink-300 ring-1 ring-white/5 transition hover:bg-ink-900 hover:text-ink-50 active:scale-90">
<Bell className="h-4 w-4" />
{notif > 0 && (
<>
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-rose-500" />
<span className="absolute right-1.5 top-1.5 h-2 w-2 animate-pulse-ring rounded-full bg-rose-500/70" />
</>
)}
</button>
<Avatar initials={initials} color={color} />
</>
);
}
export function Delta({ value, className }: { value: number; className?: string }) {
const up = value >= 0;
return (
<span className={cn('inline-flex items-center gap-0.5 text-xs font-medium', up ? 'text-emerald-400' : 'text-rose-400', className)}>
{up ? '▲' : '▼'} {Math.abs(value).toFixed(1)}%
</span>
);
}
export function Badge({ tone = 'neutral', children, className }: { tone?: 'neutral' | 'success' | 'warning' | 'error' | 'brand' | 'sky'; children: ReactNode; className?: string }) {
const tones: Record<string, string> = {
neutral: 'bg-white/5 text-ink-300 ring-white/10',
success: 'bg-emerald-500/10 text-emerald-300 ring-emerald-500/30',
warning: 'bg-amber-500/10 text-amber-300 ring-amber-500/30',
error: 'bg-rose-500/10 text-rose-300 ring-rose-500/30',
brand: 'bg-brand-500/10 text-brand-300 ring-brand-500/30',
sky: 'bg-sky-500/10 text-sky-300 ring-sky-500/30',
};
return <span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', tones[tone], className)}>{children}</span>;
}
export function Card({ className, children, style, delay }: { className?: string; children: ReactNode; style?: React.CSSProperties; delay?: number }) {
return (
<div className={cn('surface animate-fade-up p-5', className)} style={{ animationDelay: delay != null ? `${delay}ms` : undefined, ...style }}>
{children}
</div>
);
}
export function ProgressBar({ value, color = '#38bdf8', className }: { value: number; color?: string; className?: string }) {
const [w, setW] = useState(false);
useEffect(() => {
const t = requestAnimationFrame(() => requestAnimationFrame(() => setW(true)));
return () => cancelAnimationFrame(t);
}, [value]);
return (
<div className={cn('h-1.5 w-full overflow-hidden rounded-full bg-white/5', className)}>
<div className="h-full rounded-full transition-[width] duration-700 ease-smooth" style={{ width: w ? `${value}%` : 0, background: color }} />
</div>
);
}
export function SectionTitle({ title, action, sub }: { title: string; sub?: string; action?: ReactNode }) {
return (
<div className="mb-3 flex items-end justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-ink-50">{title}</h3>
{sub && <p className="text-xs text-ink-500">{sub}</p>}
</div>
{action}
</div>
);
}