Saas dashboardFree
How is the business doing, and where?
npx shadcn@latest add https://beautifulcharts.dev/r/saas-dashboard.jsonEngine: ·
Every option in the CustomizerUsage
A complete client module. Install the item first, then paste it into your app.
"use client";
import { SaasDashboardBlock, type SaasData } from "@/components/beautiful-charts/blocks/instrument/saas-dashboard";
// Your rows, in this shape. A missing value is null and stays a gap; the stats and copy recompute.
const data: SaasData = {
months: ["Jul", "Aug", "Sep"],
accounts: [
{ id: "a1", name: "Account 01", plan: "Team", segment: "Self-serve", mrr: [420, 440, 465] },
{ id: "a2", name: "Account 02", plan: "Business", segment: "Sales-led", mrr: [1800, null, 1910] },
{ id: "a3", name: "Account 03", plan: "Starter", segment: "Partners", mrr: [90, 95, null], churned: "Sep" },
],
};
export function Dashboard() {
return <SaasDashboardBlock data={data} />;
}
Props
data
SaasData
Your data (
SaasData = { months: string[]; accounts: SaasAccount[] }). Missing values arenulland stay gaps; with no rows every stat shows a dash. Leave it out to see the sample.className
string
Classes for the outer section.
| Prop | Type | Description |
|---|---|---|
| data | SaasData | Your data (SaasData = { months: string[]; accounts: SaasAccount[] }). Missing values are null and stay gaps; with no rows every stat shows a dash. Leave it out to see the sample. |
| className | string | Classes for the outer section. |
The charts inside are the Instrument area and bar charts; edit the installed file to change them.
Sourcecomponents/beautiful-charts/blocks/instrument/saas-dashboard.tsx
Show the source (228 lines)
"use client";
import { useId, useState, type CSSProperties, type KeyboardEvent } from "react";
import { AreaChart } from "../../themes/instrument/recharts/area-chart";
import { BarChart } from "../../themes/instrument/recharts/bar-chart";
import { KpiStat } from "../../themes/instrument/kpi-stat";
import { InstrumentStyles } from "../../themes/instrument/styles";
import { pct, ratio } from "../block";
export type SaasData = { months: string[]; accounts: SaasAccount[] };
/**
* One account. `mrr` has one entry for each month: a number when known, `null` when not reported (unknown, never zero).
* `joined` and `churned` name the month an account started and the month it left; before and after those it isn't
* counted at all, and only `churned` makes it a lost account.
*/
export type SaasAccount = { id: string; name: string; plan: string; segment: string; mrr: (number | null)[]; joined?: string | undefined; churned?: string | undefined };
/** The data shape, small: three months, one account that didn't report in August, one that left in September. */
export const saasExample: SaasData = {
months: ["Jul", "Aug", "Sep"],
accounts: [
{ id: "a1", name: "Account 01", plan: "Team", segment: "Self-serve", mrr: [420, 440, 465] },
{ id: "a2", name: "Account 02", plan: "Business", segment: "Sales-led", mrr: [1800, null, 1910] },
{ id: "a3", name: "Account 03", plan: "Starter", segment: "Partners", mrr: [90, 95, null], churned: "Sep" },
],
};
const MONTHS = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"];
const PLANS = ["Starter", "Team", "Business"];
const SEGMENTS = ["Self-serve", "Sales-led", "Partners"];
/** Sample: 36 accounts over twelve months, with growth, three that joined late, three that left, and one gap. */
export const saasSample: SaasData = {
months: MONTHS,
accounts: Array.from({ length: 36 }, (_, i) => {
const plan = PLANS[i % 3]!, segment = SEGMENTS[Math.floor(i / 3) % 3]!;
const start = [90, 420, 1800][i % 3]! * (1 + (i % 5) * 0.25);
const joins = i % 9 === 4 ? 5 : 0, leaves = i % 11 === 7 ? 8 : 12;
const mrr = MONTHS.map((_, m) => (m < joins || m >= leaves || (i === 20 && m === 9) ? null : Math.round(start * (1 + 0.035 * m + ((i * m) % 7) * 0.01))));
return { id: `acc-${i + 1}`, name: `Account ${String(i + 1).padStart(2, "0")}`, plan, segment, mrr, ...(joins ? { joined: MONTHS[joins] } : {}), ...(leaves < 12 ? { churned: MONTHS[leaves] } : {}) };
}),
};
const RANGES = [{ id: "3", label: "3 months" }, { id: "6", label: "6 months" }, { id: "12", label: "12 months" }] as const;
type Range = (typeof RANGES)[number]["id"];
const money = (v: number) => (Math.abs(v) >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${Math.round(v)}`);
const sum = (values: (number | null | undefined)[]) => { const known = values.filter((v): v is number => v != null && Number.isFinite(v)); return known.length ? known.reduce((a, b) => a + b, 0) : null; };
const THEME = "Instrument".toLowerCase();
/** Radio group keys (WAI-ARIA): arrows move and select, Home and End jump, focus follows; one tab stop per group. */
function radioKeys<T>(event: KeyboardEvent<HTMLElement>, values: readonly T[], current: T, pick: (value: T) => void) {
const i = Math.max(0, values.indexOf(current));
const step = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[event.key];
const next = step ? (i + step + values.length) % values.length : event.key === "Home" ? 0 : event.key === "End" ? values.length - 1 : -1;
if (next < 0) return;
event.preventDefault();
pick(values[next]!);
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus();
}
/**
* "How is the business doing, and where?" One dashboard: shared date range and segment filters, four KPIs, MRR over
* time and by plan, and the accounts behind it. Selecting a plan in the chart (pointer, keyboard or its data table)
* filters the table. Unknown values stay unknown: they're left out of totals and shown as a dash, never as zero.
*/
export function SaasDashboardBlock({ data = saasSample, className }: {
/** Your months and accounts. Leave it out to see the sample. */
data?: SaasData | undefined;
/** Classes for the outer section. */
className?: string | undefined;
}) {
const id = useId();
const [range, setRange] = useState<Range>("6");
// null is "All segments", so no segment name (even "" or "All") can collide with it.
const [segmentChoice, setSegment] = useState<string | null>(null);
const [planChoice, setPlan] = useState<string | null>(null);
const [sort, setSort] = useState<{ key: "mrr" | "change"; dir: "desc" | "asc" }>({ key: "mrr", dir: "desc" });
const [showAll, setShowAll] = useState(false);
const span = Math.min(Number(range), data.months.length);
const from = data.months.length - span, last = data.months.length - 1;
const months = data.months.slice(from);
const segments = Array.from(new Set(data.accounts.map((a) => a.segment)));
const plans = Array.from(new Set(data.accounts.map((a) => a.plan)));
// Filters always name something in the current data; after new data arrives, a stale choice simply lapses.
const segment = segmentChoice !== null && segments.includes(segmentChoice) ? segmentChoice : null;
const plan = planChoice !== null && plans.includes(planChoice) ? planChoice : null;
const inSegment = data.accounts.filter((a) => segment === null || a.segment === segment);
const monthIndex = (label: string | undefined) => (label === undefined ? -1 : data.months.indexOf(label));
/** "known": a reported value; "unknown": not reported; "out": before it joined or after it left. */
const status = (a: SaasAccount, m: number): { kind: "known"; value: number } | { kind: "unknown" } | { kind: "out" } => {
const joined = monthIndex(a.joined), churned = monthIndex(a.churned);
if ((joined >= 0 && m < joined) || (churned >= 0 && m >= churned)) return { kind: "out" };
const value = a.mrr[m];
return value != null && Number.isFinite(value) ? { kind: "known", value } : { kind: "unknown" };
};
const valueAt = (a: SaasAccount, m: number) => { const s = status(a, m); return s.kind === "known" ? s.value : null; };
/**
* A month over some accounts. One that hadn't joined or had left is a known zero; one that didn't report is unknown.
* `complete` when none is unknown. A total needs at least one reported value, or completeness (all out is a known 0).
*/
const month = (accounts: SaasAccount[], m: number) => {
let mrr = 0, active = 0, reported = 0, missing = 0;
for (const a of accounts) {
const s = status(a, m);
if (s.kind === "unknown") missing++;
else if (s.kind === "known") { reported++; mrr += s.value; if (s.value > 0) active++; }
}
const complete = accounts.length > 0 && missing === 0, shown = complete || reported > 0;
return { mrr: shown ? mrr : null, active: shown ? active : null, missing, complete };
};
// KPIs, for the range and segment. A change is shown only when both ends are complete: a partial total would
// rise or fall with who reported, not with the business.
const now = month(inSegment, last), start = month(inSegment, from);
const since = span > 1 ? months[0] : undefined;
const comparable = since !== undefined && now.complete && start.complete;
const notReported = (n: number, of: number, label: string | undefined) => (n > 0 ? `${n} of ${of} account${of === 1 ? "" : "s"} didn't report in ${label ?? "the latest month"}.` : undefined);
// Retention: the accounts paying at the start, now. One that left counts as zero; it's unknown until every one is known.
const cohort = start.complete ? inSegment.filter((a) => (valueAt(a, from) ?? 0) > 0) : [];
const cohortMissing = cohort.filter((a) => status(a, last).kind === "unknown").length;
const nrr = since !== undefined && cohort.length && !cohortMissing ? ratio(sum(cohort.map((a) => valueAt(a, last) ?? 0)), sum(cohort.map((a) => valueAt(a, from)))) : null;
const lost = since !== undefined && start.complete && cohort.length ? cohort.filter((a) => status(a, last).kind === "out").length : null;
const leftBy = (a: SaasAccount) => { const churned = monthIndex(a.churned); return churned >= 0 && churned <= last; };
// The charts show a month or plan only when it's complete; a partial one is a gap, not a dip.
const trend = months.map((label, k) => { const m = month(inSegment, from + k); return { month: label, mrr: m.complete ? m.mrr : null }; });
const byPlan = plans.map((p) => { const m = month(inSegment.filter((a) => a.plan === p), last); return { plan: p, mrr: m.complete ? m.mrr : null }; });
const selectedIndex = plan === null ? -1 : byPlan.findIndex((p) => p.plan === plan);
const rows = inSegment.filter((a) => plan === null || a.plan === plan).map((a) => {
const now = valueAt(a, last), then = valueAt(a, from);
return { ...a, now, left: leftBy(a), change: now != null && then != null && then > 0 ? (now - then) / then : null };
}).sort((x, y) => {
const a = x[sort.key === "mrr" ? "now" : "change"], b = y[sort.key === "mrr" ? "now" : "change"];
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return sort.dir === "desc" ? b - a : a - b;
});
const shown = showAll ? rows : rows.slice(0, 8);
const segmentStyle = (on: boolean): CSSProperties => ({ font: "inherit", fontSize: 13, padding: "6px 12px", borderRadius: 999, cursor: "pointer", border: "1px solid var(--i-hair)", background: on ? "var(--i-ink)" : "transparent", color: on ? "var(--i-surface)" : "var(--i-muted)" });
const cell: CSSProperties = { padding: "10px 12px", borderTop: "1px solid var(--i-hair)", textAlign: "left", fontVariantNumeric: "tabular-nums" };
const sortButton = (key: "mrr" | "change", label: string) => (
<button type="button" onClick={() => setSort((s) => ({ key, dir: s.key === key && s.dir === "desc" ? "asc" : "desc" }))} style={{ font: "inherit", color: "inherit", background: "none", border: 0, padding: 0, cursor: "pointer" }}>
{label}{sort.key === key ? (sort.dir === "desc" ? " ↓" : " ↑") : ""}
</button>
);
const pickRange = (value: string) => setRange((RANGES.find((r) => r.id === value)?.id ?? "6") as Range);
const pickSegment = (value: string | null) => { setSegment(value); setShowAll(false); };
const segmentValues: (string | null)[] = [null, ...segments];
const segmentLabel = (s: string | null) => (s === null ? "All segments" : s === "" ? "No segment" : s);
return (
<section data-bc-theme={THEME} data-bc-block="saas-dashboard" aria-labelledby={`${id}-title`} className={className}
style={{ display: "grid", gap: 16, color: "var(--i-ink)", fontFamily: "var(--i-sans)" }}>
<InstrumentStyles />
<header style={{ display: "flex", flexWrap: "wrap", alignItems: "end", justifyContent: "space-between", gap: 12 }}>
<div>
<p style={{ margin: 0, fontFamily: "var(--i-mono)", fontSize: 11, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--i-muted)" }}>Revenue · {months[0] ?? "—"} to {months[months.length - 1] ?? "—"}</p>
<h2 id={`${id}-title`} style={{ margin: "4px 0 0", fontSize: 20, fontWeight: 600 }}>How the business is doing</h2>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<div role="radiogroup" aria-label="Date range" style={{ display: "flex", gap: 4 }}>
{RANGES.map((r) => <button key={r.id} type="button" role="radio" aria-checked={range === r.id} tabIndex={range === r.id ? 0 : -1} onClick={() => pickRange(r.id)} onKeyDown={(event) => radioKeys(event, RANGES.map((x) => x.id), range, pickRange)} style={segmentStyle(range === r.id)}>{r.label}</button>)}
</div>
<div role="radiogroup" aria-label="Segment" style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{segmentValues.map((s) => <button key={s === null ? "all" : `segment:${s}`} type="button" role="radio" aria-checked={segment === s} tabIndex={segment === s ? 0 : -1} onClick={() => pickSegment(s)} onKeyDown={(event) => radioKeys(event, segmentValues, segment, pickSegment)} style={segmentStyle(segment === s)}>{segmentLabel(s)}</button>)}
</div>
</div>
</header>
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))" }}>
{/* Notes about missing reports go in the description: visible, and read in order (a unit is hidden from screen readers). */}
<KpiStat density="compact" title="MRR" value={now.mrr} previous={comparable ? start.mrr : null} previousLabel={comparable ? since : undefined} trend={trend.map((t) => t.mrr)} valueFormatter={money}
description={notReported(now.missing, inSegment.length, data.months[last]) ?? (since && !start.complete ? notReported(start.missing, inSegment.length, since) : undefined)} />
<KpiStat density="compact" title="Active accounts" value={now.active} previous={comparable ? start.active : null} previousLabel={comparable ? since : undefined}
description={notReported(now.missing, inSegment.length, data.months[last])} />
<KpiStat density="compact" title="Net revenue retention" value={nrr} valueFormatter={(v) => pct(v)} target={1} unit={nrr !== null ? `of ${since} MRR kept` : undefined}
description={cohortMissing ? notReported(cohortMissing, cohort.length, data.months[last]) : undefined} />
<KpiStat density="compact" title="Accounts lost" value={lost} polarity="down" unit={lost !== null ? `of ${cohort.length} paying in ${since}` : undefined} />
</div>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))" }}>
<AreaChart data={trend} xKey="month" eyebrow={segmentLabel(segment)} title="MRR over time" config={{ mrr: { label: "MRR" } }} valueFormatter={money} legend={false} readout={false} />
<BarChart data={byPlan} xKey="plan" eyebrow="Select a plan to filter the accounts" title="MRR by plan, this month" config={{ mrr: { label: "MRR" } }} valueFormatter={money} legend={false} readout={false}
selected={selectedIndex >= 0 ? selectedIndex : null}
onSelectedChange={(selection) => { setPlan(selection && selection.datum.plan !== plan ? selection.datum.plan : null); setShowAll(false); }} />
</div>
<div style={{ border: "1px solid var(--i-hair)", borderRadius: "var(--i-radius)", background: "var(--i-surface)", overflow: "hidden" }}>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, padding: "12px 12px 8px" }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Accounts</h3>
<span style={{ fontSize: 13, color: "var(--i-muted)" }}>{rows.length} of {data.accounts.length}</span>
{plan !== null ? <button type="button" onClick={() => setPlan(null)} aria-label={`Clear the ${plan} plan filter`} style={{ ...segmentStyle(true), padding: "2px 10px", fontSize: 12 }}>{plan} ×</button> : null}
</div>
<div tabIndex={0} role="region" aria-label="Accounts table" style={{ overflowX: "auto" }}>
<table style={{ width: "100%", minWidth: 520, borderCollapse: "collapse", fontSize: 13 }}>
<caption style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }}>Accounts{plan !== null ? ` on the ${plan} plan` : ""}{segment !== null ? `, ${segmentLabel(segment)}` : ""}</caption>
<thead><tr style={{ color: "var(--i-muted)" }}>
<th scope="col" style={{ ...cell, borderTop: 0, fontWeight: 500 }}>Account</th>
<th scope="col" style={{ ...cell, borderTop: 0, fontWeight: 500 }}>Plan</th>
<th scope="col" style={{ ...cell, borderTop: 0, fontWeight: 500 }}>Segment</th>
<th scope="col" aria-sort={sort.key === "mrr" ? (sort.dir === "desc" ? "descending" : "ascending") : "none"} style={{ ...cell, borderTop: 0, fontWeight: 500, textAlign: "right" }}>{sortButton("mrr", "MRR")}</th>
<th scope="col" aria-sort={sort.key === "change" ? (sort.dir === "desc" ? "descending" : "ascending") : "none"} style={{ ...cell, borderTop: 0, fontWeight: 500, textAlign: "right" }}>{sortButton("change", since ? `Change since ${months[0]}` : "Change")}</th>
</tr></thead>
<tbody>
{shown.map((r) => (
<tr key={r.id}>
<th scope="row" style={{ ...cell, fontWeight: 500 }}>{r.name}</th>
<td style={cell}>{r.plan}</td>
<td style={{ ...cell, color: "var(--i-muted)" }}>{segmentLabel(r.segment)}</td>
<td style={{ ...cell, textAlign: "right", color: r.now == null ? "var(--i-muted)" : undefined }}>{r.now != null ? money(r.now) : r.left ? "Left" : "—"}</td>
{/* The theme's delta chip: each theme gives its tone text enough contrast (a raw tone colour can fall short). */}
<td style={{ ...cell, textAlign: "right" }}>{r.change == null ? <span style={{ color: "var(--i-muted)" }}>—</span> : <span data-slot="readout-delta" data-tone={r.change > 0 ? "good" : r.change < 0 ? "bad" : "neutral"} style={{ marginTop: 0 }}>{`${r.change > 0 ? "+" : r.change < 0 ? "−" : ""}${pct(Math.abs(r.change))}`}</span>}</td>
</tr>
))}
{!rows.length ? <tr><td colSpan={5} style={{ ...cell, color: "var(--i-muted)" }}>No accounts match these filters.</td></tr> : null}
</tbody>
</table>
</div>
{rows.length > 8 ? <button type="button" onClick={() => setShowAll((v) => !v)} style={{ font: "inherit", fontSize: 13, width: "100%", padding: 10, border: 0, borderTop: "1px solid var(--i-hair)", background: "none", color: "var(--i-muted)", cursor: "pointer" }}>{showAll ? "Show fewer" : `Show all ${rows.length}`}</button> : null}
</div>
</section>
);
}
Details
- Pointer
- Hover shows a crosshair and a tooltip with every series at that point; click pins it. Clicking a legend item hides or shows its series.
- Keyboard
- Tab lands once on the plot. Left and Right move between points (the first press goes to the latest), Up and Down change series, Home and End jump to the ends, Enter or Space pins, Escape clears.
- Screen readers
- Every move is announced as a sentence, not coordinates. "Show data table" opens the numbers as a real table, and missing values stay empty, never zero.
- Motion
- Bars grow from the baseline, staggered in data order, then the labels fade in. The headline number rolls in. With reduced motion everything appears settled at once, and nothing depends on the motion to be read.