JSX
Copy
// Chart cards drawn as inline SVG from each card's own data (bar, area and donut), no chart library.
// The data table is always in the page for screen readers (visually hidden) and "Show table" puts it
// on screen. Charts re-measure with a ResizeObserver so they stay crisp at any width.
// Usage: <ChartCards /> renders the demo trio, or use one card:
// <ChartCard type="bar" title="Orders by weekday" subtitle="…" caption="…"
// columns={['Day', 'Orders']} rows={[{ label: 'Mon', value: 842 }, …]} format="number" />
import { useId, useLayoutEffect, useRef, useState } from 'react';
const COLORS = { c1: '#2a78d6', c2: '#eb6834', c3: '#1baf7a', c4: '#eda100', ink: '#0f172a', muted: '#4b5768', grid: '#e2e8f0', surface: '#fff' };
const SERIES = [COLORS.c1, COLORS.c2, COLORS.c3, COLORS.c4];
const FORMATS = {
number: new Intl.NumberFormat('en-US'),
usd: new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }),
'usd-compact': new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 }),
};
const usdShort = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact' });
const r1 = (n) => Math.round(n * 10) / 10;
const niceStep = (range, steps) => {
const raw = range / steps, mag = 10 ** Math.floor(Math.log10(raw || 1));
return [1, 2, 2.5, 5, 10].map((s) => s * mag).find((s) => s >= raw);
};
function BarChart({ rows, width: w, format: f }) {
const h = 200, top = 22, base = h - 26, band = (w - 8) / rows.length, bw = Math.min(38, band * 0.62);
const max = Math.max(...rows.map((r) => r.value));
return (
<svg viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true" focusable="false">
{rows.map((r, i) => {
const x = 4 + band * i + (band - bw) / 2, y = base - (r.value / max) * (base - top), rr = Math.min(4, bw / 3);
const d = `M${r1(x)} ${base}V${r1(y + rr)}Q${r1(x)} ${r1(y)} ${r1(x + rr)} ${r1(y)}H${r1(x + bw - rr)}Q${r1(x + bw)} ${r1(y)} ${r1(x + bw)} ${r1(y + rr)}V${base}Z`;
return (
<g key={r.label}>
<path fill={COLORS.c1} d={d}><title>{`${r.label}: ${f.format(r.value)}`}</title></path>
<text x={r1(x + bw / 2)} y={r1(y - 6)} textAnchor="middle" fontSize="11" fontWeight="600" fill={COLORS.ink}>{f.format(r.value)}</text>
<text x={r1(x + bw / 2)} y={h - 8} textAnchor="middle" fontSize="11" fill={COLORS.muted}>{r.label}</text>
</g>
);
})}
<line x1="0" x2={w} y1={base} y2={base} stroke={COLORS.muted} strokeWidth="1" />
</svg>
);
}
function AreaChart({ rows, width: w, format: f, valueText }) {
const h = 220, top = 18, base = h - 26, left = 46, right = w - 12, vals = rows.map((r) => r.value);
const step = niceStep(Math.max(...vals) - Math.min(...vals), 4);
const lo = Math.floor(Math.min(...vals) / step) * step, hi = Math.ceil(Math.max(...vals) / step) * step;
const x = (i) => left + (i * (right - left)) / (rows.length - 1);
const y = (v) => base - ((v - lo) / (hi - lo)) * (base - top);
const ticks = [];
for (let v = lo; v <= hi + step / 2; v += step) ticks.push(v);
const every = (right - left) / rows.length < 34 ? 2 : 1;
const pts = rows.map((r, i) => [r1(x(i)), r1(y(r.value))]);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0]} ${p[1]}`).join('');
const last = pts.at(-1);
return (
<svg viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true" focusable="false">
{ticks.map((v) => (
<g key={v}>
<line x1={left} x2={right} y1={r1(y(v))} y2={r1(y(v))} stroke={v === lo ? COLORS.muted : COLORS.grid} strokeWidth="1" />
<text x={left - 8} y={r1(y(v) + 4)} textAnchor="end" fontSize="11" fill={COLORS.muted}>{f.format(v)}</text>
</g>
))}
<path d={`${d}L${last[0]} ${base}L${pts[0][0]} ${base}Z`} fill={COLORS.c1} fillOpacity=".12" />
<path d={d} fill="none" stroke={COLORS.c1} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{rows.map((r, i) => (
<g key={r.label}>
{(rows.length - 1 - i) % every === 0 && (
<text x={pts[i][0]} y={h - 8} textAnchor="middle" fontSize="11" fill={COLORS.muted}>{r.label}</text>
)}
<circle cx={pts[i][0]} cy={pts[i][1]} r={i === rows.length - 1 ? 5 : 3.5} fill={COLORS.c1} stroke={COLORS.surface} strokeWidth="2">
<title>{`${r.label}: ${valueText(r.value)}`}</title>
</circle>
</g>
))}
<text x={last[0] - 10} y={last[1] - 12} textAnchor="end" fontSize="12" fontWeight="700" fill={COLORS.ink}>{f.format(vals.at(-1))}</text>
</svg>
);
}
function DonutChart({ rows, valueText }) {
const total = rows.reduce((a, r) => a + r.value, 0);
let at = 0;
return (
<svg viewBox="0 0 170 170" width="170" height="170" aria-hidden="true" focusable="false">
<g transform="rotate(-90 85 85)">
{rows.map((r, i) => {
const p = (r.value / total) * 100, offset = at;
at += p;
return (
<circle key={r.label} cx="85" cy="85" r="64" fill="none" stroke={SERIES[i % 4]} strokeWidth="26" pathLength="100" strokeDasharray={`${r1(p - 0.8)} ${r1(100 - p + 0.8)}`} strokeDashoffset={r1(-offset)}>
<title>{`${r.label}: ${valueText(r.value)}`}</title>
</circle>
);
})}
</g>
<text x="85" y="84" textAnchor="middle" fontSize="20" fontWeight="750" fill={COLORS.ink}>{usdShort.format(total)}</text>
<text x="85" y="103" textAnchor="middle" fontSize="11" fill={COLORS.muted}>total</text>
</svg>
);
}
// Measures an element's content width and keeps it current.
function useWidth() {
const ref = useRef(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => setWidth(Math.max(240, Math.floor(el.clientWidth)));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width];
}
const th = 'border-b border-[#e2e8f0] px-2 py-1.5 text-[.74rem] font-semibold tracking-[.06em] text-[#4b5768] uppercase';
const td = 'border-b border-[#e2e8f0] px-2 py-1.5 text-right';
function ChartCard({ type, title, subtitle, caption, columns, rows, format = 'number', tableFormat = 'number' }) {
const tableId = useId();
const [showTable, setShowTable] = useState(false);
const [canvasRef, width] = useWidth();
const f = FORMATS[format];
const valueText = (v) => FORMATS[tableFormat].format(v);
const total = rows.reduce((a, r) => a + r.value, 0);
const share = (v) => `${Math.round((v / total) * 100)}%`;
const canvasCls = '[&_svg]:block [&_svg]:h-auto [&_svg]:w-full [&_svg]:overflow-visible';
return (
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border border-[#e2e8f0] bg-white px-[18px] pt-[18px] pb-4 text-[#0f172a] shadow-[0_1px_2px_rgba(15,23,42,.04),0_8px_24px_rgba(15,23,42,.05)]">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-bold">{title}</h3>
<p className="text-[.8rem] text-[#4b5768] tabular-nums">{subtitle}</p>
</div>
<button
type="button"
aria-controls={tableId}
aria-expanded={showTable}
onClick={() => setShowTable(!showTable)}
className="flex-none cursor-pointer rounded-lg border border-[#9bb3cf] bg-white px-2.5 py-[5px] text-[.78rem] font-semibold text-[#1f5fae] transition-colors hover:bg-[#eef4fb] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1f5fae] motion-reduce:transition-none"
>
{showTable ? 'Hide table' : 'Show table'}
</button>
</div>
{type === 'donut' ? (
<div className="flex flex-wrap items-center gap-[22px]">
<div className={`flex-[0_0_170px] ${canvasCls}`}><DonutChart rows={rows} valueText={valueText} /></div>
<ul aria-hidden="true" className="grid flex-[1_1_160px] gap-2 text-[.84rem] [&_b]:font-bold [&_i]:size-3 [&_i]:rounded-[3px] [&_li]:grid [&_li]:grid-cols-[12px_1fr_auto_auto] [&_li]:items-center [&_li]:gap-2 [&_li]:tabular-nums [&_li>span:last-child]:min-w-[3ch] [&_li>span:last-child]:text-right [&_li>span:last-child]:text-[#4b5768]">
{rows.map((r, i) => (
<li key={r.label}><i style={{ background: SERIES[i % 4] }} /><span>{r.label}</span><b>{valueText(r.value)}</b><span>{share(r.value)}</span></li>
))}
</ul>
</div>
) : (
<div ref={canvasRef} className={canvasCls}>
{width > 0 && (type === 'bar'
? <BarChart rows={rows} width={width} format={f} />
: <AreaChart rows={rows} width={width} format={f} valueText={valueText} />)}
</div>
)}
<div id={tableId} className={showTable ? undefined : 'sr-only'}>
<table className="w-full border-collapse text-[.84rem] tabular-nums">
<caption className="pb-1.5 text-left text-[.74rem] text-[#4b5768]">{caption}</caption>
<thead>
<tr>
{columns.map((c, i) => (
<th key={c} scope="col" className={`${th} ${i ? 'text-right' : 'text-left'}`}>{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.label}>
<th scope="row" className="border-b border-[#e2e8f0] px-2 py-1.5 text-left font-medium">{r.label}</th>
<td className={td}>{valueText(r.value)}</td>
{type === 'donut' && <td className={td}>{share(r.value)}</td>}
</tr>
))}
</tbody>
</table>
</div>
</article>
);
}
const defaultCharts = [
{
label: 'Bar chart', type: 'bar', title: 'Orders by weekday', subtitle: 'Week of Oct 5, 2026 · 6,911 orders',
caption: 'Orders by weekday, week of Oct 5, 2026', columns: ['Day', 'Orders'], format: 'number', tableFormat: 'number',
rows: [
{ label: 'Mon', value: 842 }, { label: 'Tue', value: 913 }, { label: 'Wed', value: 1027 }, { label: 'Thu', value: 968 },
{ label: 'Fri', value: 1284 }, { label: 'Sat', value: 1146 }, { label: 'Sun', value: 731 },
],
},
{
label: 'Donut chart with legend', type: 'donut', title: 'Revenue by channel', subtitle: 'Q3 2026 · $480,000 total',
caption: 'Revenue by channel, Q3 2026', columns: ['Channel', 'Revenue', 'Share'], format: 'usd', tableFormat: 'usd',
rows: [
{ label: 'Organic search', value: 182400 }, { label: 'Paid social', value: 115200 },
{ label: 'Email', value: 96000 }, { label: 'Referral', value: 86400 },
],
},
{
label: 'Area chart', wide: true, type: 'line', title: 'Monthly recurring revenue', subtitle: 'Jan–Oct 2026 · up 49.2% since January',
caption: 'Monthly recurring revenue, January to October 2026', columns: ['Month', 'MRR'], format: 'usd-compact', tableFormat: 'usd',
rows: [
{ label: 'Jan', value: 86410 }, { label: 'Feb', value: 90120 }, { label: 'Mar', value: 94730 }, { label: 'Apr', value: 99050 },
{ label: 'May', value: 104200 }, { label: 'Jun', value: 108870 }, { label: 'Jul', value: 112640 }, { label: 'Aug', value: 117420 },
{ label: 'Sep', value: 123860 }, { label: 'Oct', value: 128940 },
],
},
];
export default function ChartCards({ charts = defaultCharts }) {
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,380px),1fr))] gap-x-[18px] gap-y-9">
{charts.map(({ label, wide, ...chart }) => (
<div key={chart.title} className={wide ? 'col-span-full' : undefined}>
<span className="mb-4 block text-[.72rem] tracking-[.12em] text-[#4b5768] uppercase">{label}</span>
<ChartCard {...chart} />
</div>
))}
</div>
);
}