Finance Dashboard
Personal and business banking with a balance hero card, income vs expense bars, spending categories, transactions and quick transfer.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/examples-finance.json02
Or copy the source below (4 files). Dependencies: lucide-react.
Source
components/niceui/pages/examples/FinanceDashboard.tsx
import { ArrowDownRight, ArrowUpRight, CreditCard, DollarSign, Receipt, Wallet, Building2, Settings, Bell, PieChart, Repeat, Send } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/cn';
import { BarChart, CountUp } from './shared/charts';
import { AppShell, Avatar, Badge, Card, Delta, ProgressBar, SectionTitle, TopBar, avatarColors, type NavItem } from './shared/shell';
const nav: NavItem[] = [
{ id: 'finance', label: 'Overview', icon: DollarSign },
{ id: 'accounts', label: 'Accounts', icon: Building2 },
{ id: 'cards', label: 'Cards', icon: CreditCard },
{ id: 'payments', label: 'Payments', icon: Repeat },
{ id: 'invoices', label: 'Invoices', icon: Receipt },
{ id: 'reports', label: 'Reports', icon: PieChart },
{ id: 'settings', label: 'Settings', icon: Settings },
];
const monthly = [
{ label: 'Jan', values: { in: 38, out: 24 } }, { label: 'Feb', values: { in: 42, out: 28 } },
{ label: 'Mar', values: { in: 35, out: 31 } }, { label: 'Apr', values: { in: 48, out: 26 } },
{ label: 'May', values: { in: 52, out: 33 } }, { label: 'Jun', values: { in: 46, out: 29 } },
{ label: 'Jul', values: { in: 58, out: 35 } }, { label: 'Aug', values: { in: 61, out: 38 } },
{ label: 'Sep', values: { in: 54, out: 34 } }, { label: 'Oct', values: { in: 67, out: 41 } },
{ label: 'Nov', values: { in: 72, out: 39 } }, { label: 'Dec', values: { in: 81, out: 44 } },
];
const categories = [
{ label: 'Payroll', value: 48000, pct: 58, color: '#38bdf8' },
{ label: 'Infrastructure', value: 18400, pct: 22, color: '#34d399' },
{ label: 'Marketing', value: 9600, pct: 12, color: '#fbbf24' },
{ label: 'Office', value: 4200, pct: 5, color: '#fb7185' },
{ label: 'Other', value: 2600, pct: 3, color: '#8a8a96' },
];
const txns = [
{ name: 'Stripe payout', sub: 'inbound transfer', amount: 18420, type: 'income', date: 'Mar 29', status: 'completed', who: 'SP' },
{ name: 'AWS Hosting', sub: 'invoice #INV-2041', amount: -3280, type: 'expense', date: 'Mar 28', status: 'completed', who: 'AW' },
{ name: 'Figma', sub: 'team subscription', amount: -540, type: 'expense', date: 'Mar 27', status: 'completed', who: 'FG' },
{ name: 'Notion Labs', sub: 'annual renewal', amount: -1200, type: 'expense', date: 'Mar 26', status: 'pending', who: 'NT' },
{ name: 'Acme Corp', sub: 'consulting retainer', amount: 9800, type: 'income', date: 'Mar 24', status: 'completed', who: 'AC' },
{ name: 'Glide Robotics', sub: 'contract NDA-118', amount: -2400, type: 'expense', date: 'Mar 22', status: 'failed', who: 'GR' },
];
const contacts = [
{ name: 'Mira Chen', handle: '@mira', color: 'azure' as const },
{ name: 'Joon Sohn', handle: '@joon', color: 'emerald' as const },
{ name: 'Lina Vega', handle: '@lina', color: 'rose' as const },
{ name: 'Ravi Desai', handle: '@ravi', color: 'amber' as const },
{ name: 'Maya Klein', handle: '@maya', color: 'sky' as const },
];
const bills = [
{ label: 'AWS Hosting', due: 'Apr 3', amount: 3280, color: 'amber' as const },
{ label: 'Office lease', due: 'Apr 5', amount: 8400, color: 'rose' as const },
{ label: 'Health benefits', due: 'Apr 9', amount: 4120, color: 'sky' as const },
];
const TABS = ['All', 'Income', 'Expense'] as const;
export function FinanceDashboard() {
const [tab, setTab] = useState<(typeof TABS)[number]>('All');
const [amount, setAmount] = useState('2,500');
const [active, setActive] = useState('finance');
const filtered = txns.filter((t) => tab === 'All' || t.type === tab.toLowerCase());
return (
<AppShell
brand={<><span className="grid h-8 w-8 place-items-center rounded-lg bg-emerald-500/15 text-emerald-300"><Wallet className="h-4 w-4" /></span><span className="text-sm font-semibold text-ink-50">Mercury</span></>}
nav={nav}
active={active}
onNav={setActive}
topbar={<TopBar dateLabel="Mar 2026" notif={2} initials="MC" color="emerald" />}
>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
{/* Balance hero */}
<Card className="xl:col-span-2 overflow-hidden p-0" delay={0}>
<div className="relative grid-bg p-6" style={{ background: 'linear-gradient(135deg, #1461e1 0%, #174eb6 45%, #0a0a0c 120%)' }}>
<div className="flex items-start justify-between">
<div>
<p className="text-xs text-white/70">Total balance</p>
<div className="mt-1 text-4xl font-semibold text-white"><CountUp value={284932} prefix="$" /></div>
<Delta value={12.4} className="text-white/80" />
</div>
<span className="flex items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-xs text-white backdrop-blur"><span className="h-1.5 w-1.5 rounded-full bg-emerald-300" /> Checking · 4242</span>
</div>
<div className="mt-8 flex items-center justify-between font-mono text-lg tracking-[0.2em] text-white/90">
<span>4242</span><span className="text-white/40">····</span><span className="text-white/40">····</span><span>1005</span>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-white/60">
<span>MIRA CHEN</span><span>VISA · 09/27</span>
</div>
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/10 blur-2xl" />
</div>
<div className="grid grid-cols-3 divide-x divide-white/5">
<div className="p-4"><p className="text-xs text-ink-500">Income</p><p className="mt-1 text-lg font-semibold text-emerald-400">$81.2k</p></div>
<div className="p-4"><p className="text-xs text-ink-500">Expense</p><p className="mt-1 text-lg font-semibold text-rose-400">$44.1k</p></div>
<div className="p-4"><p className="text-xs text-ink-500">Net</p><p className="mt-1 text-lg font-semibold text-ink-50">$37.1k</p></div>
</div>
</Card>
{/* Quick transfer */}
<Card delay={80}>
<SectionTitle title="Quick transfer" />
<div className="flex gap-2 overflow-x-auto pb-1">
{contacts.map((c) => (
<button key={c.handle} className="group flex shrink-0 flex-col items-center gap-1.5 rounded-xl p-2 transition-colors hover:bg-white/[0.03]">
<Avatar initials={c.name.split(' ').map((w) => w[0]).join('')} color={c.color} ring />
<span className="text-[11px] text-ink-300 group-hover:text-ink-50">{c.name.split(' ')[0]}</span>
</button>
))}
</div>
<div className="mt-4 space-y-3">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-500">$</span>
<input value={amount} onChange={(e) => setAmount(e.target.value)} className="focus-ring h-11 w-full rounded-xl bg-ink-850 pl-7 pr-3 text-lg font-medium text-ink-50 ring-1 ring-white/5 focus:ring-brand-500/40" />
</div>
<button className="focus-ring flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-600 text-sm font-medium text-white transition hover:bg-brand-500 active:scale-95">
<Send className="h-4 w-4" /> Send transfer
</button>
</div>
</Card>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2" delay={160}>
<SectionTitle title="Income vs expense" sub="2026 · in thousands" />
<BarChart
height={240}
groups={monthly}
series={[{ key: 'in', label: 'Income', color: '#34d399' }, { key: 'out', label: 'Expense', color: '#fb7185' }]}
format={(v) => `$${v}k`}
/>
</Card>
<Card delay={220}>
<SectionTitle title="Spending categories" sub="This month" />
<div className="space-y-3">
{categories.map((c) => (
<div key={c.label}>
<div className="flex items-center justify-between text-sm"><span className="text-ink-300">{c.label}</span><span className="text-ink-100">${(c.value / 1000).toFixed(1)}k</span></div>
<ProgressBar value={c.pct} color={c.color} className="mt-1.5" />
</div>
))}
</div>
</Card>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2" delay={300}>
<SectionTitle title="Transactions" action={
<div className="flex rounded-lg bg-ink-850 p-0.5">
{TABS.map((t) => (
<button key={t} onClick={() => setTab(t)} className={cn('relative rounded-md px-3 py-1 text-xs font-medium transition-colors', tab === t ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200')}>
{tab === t && <span className="absolute inset-0 rounded-md bg-white/10 shadow-hairline" />}
<span className="relative">{t}</span>
</button>
))}
</div>
} />
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead><tr className="text-xs text-ink-500"><th className="pb-2 font-medium">Merchant</th><th className="pb-2 font-medium">Date</th><th className="pb-2 font-medium">Status</th><th className="pb-2 text-right font-medium">Amount</th></tr></thead>
<tbody>
{filtered.map((t) => (
<tr key={t.name + t.date} className="border-t border-white/5 transition-colors hover:bg-white/[0.02]">
<td className="py-2.5"><div className="flex items-center gap-2.5"><Avatar initials={t.who} color={t.type === 'income' ? 'emerald' : 'rose'} size="sm" /><div><div className="text-ink-100">{t.name}</div><div className="text-xs text-ink-500">{t.sub}</div></div></div></td>
<td className="py-2.5 text-ink-400">{t.date}</td>
<td className="py-2.5"><Badge tone={t.status === 'completed' ? 'success' : t.status === 'pending' ? 'warning' : 'error'}>{t.status}</Badge></td>
<td className={cn('py-2.5 text-right font-medium', t.amount > 0 ? 'text-emerald-400' : 'text-ink-100')}>{t.amount > 0 ? '+' : ''}${Math.abs(t.amount).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card delay={360}>
<SectionTitle title="Upcoming bills" sub="Next 14 days" />
<div className="space-y-2">
{bills.map((b, i) => (
<div key={b.label} className="flex items-center gap-3 rounded-xl bg-ink-850 p-3 animate-fade-up" style={{ animationDelay: `${360 + i * 70}ms` }}>
<span className={cn('grid h-9 w-9 place-items-center rounded-lg bg-gradient-to-br text-white', avatarColors[b.color])}><Receipt className="h-4 w-4" /></span>
<div><div className="text-sm text-ink-100">{b.label}</div><div className="text-xs text-ink-500">Due {b.due}</div></div>
<span className="ml-auto font-semibold text-ink-50">${b.amount.toLocaleString()}</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>
);
}