Analytics Dashboard New
SaaS product analytics with KPI sparklines, revenue area chart, traffic donut, top pages, live visitors and activity feed.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/examples-analytics.json02
Or copy the source below (4 files). Dependencies: lucide-react.
Source
components/niceui/pages/examples/AnalyticsDashboard.tsx
import { Activity, BarChart3, Globe, LayoutDashboard, PieChart, Settings, Sparkles, Users } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/cn';
import { AreaChart, CountUp, DonutChart, Sparkline } from './shared/charts';
import { AppShell, Avatar, Badge, Card, Delta, ProgressBar, SectionTitle, TopBar, avatarColors, type NavItem } from './shared/shell';
const nav: NavItem[] = [
{ id: 'analytics', label: 'Analytics', icon: LayoutDashboard },
{ id: 'users', label: 'Users', icon: Users, badge: 12 },
{ id: 'traffic', label: 'Traffic', icon: Globe },
{ id: 'funnels', label: 'Funnels', icon: BarChart3 },
{ id: 'retention', label: 'Retention', icon: PieChart },
{ id: 'events', label: 'Live Events', icon: Activity },
{ id: 'settings', label: 'Settings', icon: Settings },
];
const kpis = [
{ label: 'Revenue', value: 284932, prefix: '$', delta: 12.4, spark: [12, 18, 14, 22, 19, 28, 24, 31, 27, 34], color: '#38bdf8', icon: 'Revenue' },
{ label: 'Active Users', value: 48210, delta: 8.1, spark: [30, 32, 28, 35, 33, 40, 38, 44, 41, 47], color: '#34d399' },
{ label: 'Conversion', value: 4.82, suffix: '%', decimals: 2, delta: 2.3, spark: [3.1, 3.4, 3.2, 3.8, 4.0, 4.2, 4.1, 4.5, 4.6, 4.8], color: '#fbbf24' },
{ label: 'Churn', value: 1.92, suffix: '%', decimals: 2, delta: -0.6, spark: [2.4, 2.2, 2.3, 2.1, 2.0, 2.1, 1.9, 2.0, 1.95, 1.92], color: '#fb7185' },
];
function gen(days: number) {
let v = 22000 + Math.random() * 4000;
return Array.from({ length: days }, (_, i) => {
v += (Math.random() - 0.45) * 2400;
v = Math.max(14000, v);
return { label: `D${i + 1}`, value: Math.round(v) };
});
}
const traffic = [
{ label: 'Organic', value: 42, color: '#38bdf8' },
{ label: 'Direct', value: 26, color: '#34d399' },
{ label: 'Referral', value: 18, color: '#fbbf24' },
{ label: 'Social', value: 14, color: '#fb7185' },
];
const topPages = [
{ path: '/pricing', views: 18420, conv: 7.4 },
{ path: '/features', views: 15320, conv: 5.1 },
{ path: '/blog/launch-week', views: 12104, conv: 9.2 },
{ path: '/docs/quickstart', views: 9880, conv: 3.4 },
{ path: '/changelog', views: 7612, conv: 6.0 },
];
const countries = [
{ flag: '🇺🇸', name: 'United States', visitors: 12480, color: 'azure' as const },
{ flag: '🇬🇧', name: 'United Kingdom', visitors: 6420, color: 'sky' as const },
{ flag: '🇩🇪', name: 'Germany', visitors: 5210, color: 'emerald' as const },
{ flag: '🇯🇵', name: 'Japan', visitors: 4380, color: 'rose' as const },
{ flag: '🇧🇷', name: 'Brazil', visitors: 3120, color: 'amber' as const },
];
const feed = [
{ who: 'AC', color: 'azure' as const, text: 'Acme Corp upgraded to Pro plan', time: '2m ago' },
{ who: 'JS', color: 'emerald' as const, text: 'Joon Sohn invited 3 teammates', time: '14m ago' },
{ who: 'LV', color: 'rose' as const, text: 'Lina Vega started a trial', time: '26m ago' },
{ who: 'RD', color: 'amber' as const, text: 'Ravi Desai connected Slack', time: '41m ago' },
{ who: 'MK', color: 'sky' as const, text: 'Maya Klein exported Q1 report', time: '1h ago' },
];
const RANGES = ['7D', '30D', '90D'] as const;
export function AnalyticsDashboard() {
const [range, setRange] = useState<(typeof RANGES)[number]>('30D');
const [data, setData] = useState(() => gen(30));
const [active, setActive] = useState('analytics');
const switchRange = (r: (typeof RANGES)[number]) => {
setRange(r);
const days = r === '7D' ? 7 : r === '30D' ? 30 : 90;
setData(gen(days));
};
return (
<AppShell
brand={<><span className="grid h-8 w-8 place-items-center rounded-lg bg-brand-500/15 text-brand-300"><Sparkles className="h-4 w-4" /></span><span className="text-sm font-semibold text-ink-50">NiceUI</span><Badge tone="brand" className="ml-1">Analytics</Badge></>}
nav={nav}
active={active}
onNav={setActive}
topbar={<TopBar dateLabel="Mar 1 – Mar 30, 2026" notif={3} initials="MK" color="azure" />}
>
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-ink-50">Analytics overview</h1>
<p className="text-sm text-ink-400">Product performance for the last {range}.</p>
</div>
<Badge tone="brand"><span className="h-1.5 w-1.5 rounded-full bg-brand-400" /> New</Badge>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{kpis.map((k, i) => (
<Card key={k.label} delay={i * 70}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-ink-400">{k.label}</span>
<Delta value={k.delta} />
</div>
<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-2"><Sparkline data={k.spark} color={k.color} width={200} height={32} className="w-full" /></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="Revenue over time"
action={
<div className="flex rounded-lg bg-ink-850 p-0.5">
{RANGES.map((r) => (
<button key={r} onClick={() => switchRange(r)} className={cn('relative rounded-md px-3 py-1 text-xs font-medium transition-colors', range === r ? 'text-ink-50' : 'text-ink-400 hover:text-ink-200')}>
{range === r && <span className="absolute inset-0 rounded-md bg-white/10 shadow-hairline" />}
<span className="relative">{r}</span>
</button>
))}
</div>
}
/>
<AreaChart data={data} height={260} format={(v) => `$${(v / 1000).toFixed(1)}k`} />
</Card>
<Card delay={360}>
<SectionTitle title="Traffic sources" sub="Last 30 days" />
<div className="flex items-center gap-5">
<DonutChart data={traffic} center={<><span className="text-lg font-semibold text-ink-50">100%</span><span className="text-[10px] text-ink-500">sessions</span></>}>
</DonutChart>
<div className="flex-1 space-y-2.5">
{traffic.map((t) => (
<div key={t.label} className="flex items-center gap-2 text-sm">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: t.color }} />
<span className="text-ink-300">{t.label}</span>
<span className="ml-auto font-medium text-ink-100">{t.value}%</span>
</div>
))}
</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={420}>
<SectionTitle title="Top pages" sub="By sessions" action={<button className="text-xs text-brand-300 hover:text-brand-200">View all</button>} />
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] text-left text-sm">
<thead><tr className="text-xs text-ink-500"><th className="pb-2 font-medium">Page</th><th className="pb-2 text-right font-medium">Sessions</th><th className="pb-2 font-medium">Conv. rate</th></tr></thead>
<tbody>
{topPages.map((p) => (
<tr key={p.path} className="border-t border-white/5 transition-colors hover:bg-white/[0.02]">
<td className="py-2.5 font-mono text-[13px] text-ink-100">{p.path}</td>
<td className="py-2.5 text-right text-ink-300">{p.views.toLocaleString()}</td>
<td className="py-2.5"><div className="flex items-center gap-2"><div className="w-24"><ProgressBar value={p.conv * 10} color="#38bdf8" /></div><span className="text-xs text-ink-300">{p.conv}%</span></div></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card delay={480}>
<SectionTitle title="Live visitors" action={<span className="flex items-center gap-1.5 text-xs text-emerald-400"><span className="relative flex h-2 w-2"><span className="absolute inline-flex h-full w-full animate-pulse-ring rounded-full bg-emerald-500/70" /><span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" /></span>{Math.floor(420 + Math.random() * 60)}</span>} />
<div className="space-y-2">
{countries.map((c, i) => (
<div key={c.name} className="flex items-center gap-3 animate-fade-up" style={{ animationDelay: `${480 + i * 60}ms` }}>
<span className="text-base">{c.flag}</span>
<span className="text-sm text-ink-200">{c.name}</span>
<span className="ml-auto text-xs text-ink-400">{c.visitors.toLocaleString()}</span>
<span className={cn('h-1.5 w-12 rounded-full bg-gradient-to-r', avatarColors[c.color])} />
</div>
))}
</div>
</Card>
</div>
<Card className="mt-4" delay={540}>
<SectionTitle title="Recent activity" />
<div className="space-y-1">
{feed.map((f, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-white/[0.02] animate-fade-up" style={{ animationDelay: `${540 + i * 60}ms` }}>
<Avatar initials={f.who} color={f.color} size="sm" />
<span className="text-sm text-ink-200">{f.text}</span>
<span className="ml-auto text-xs text-ink-500">{f.time}</span>
</div>
))}
</div>
</Card>
</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>
);
}