Search for pages, actions, and quick links.
Funnels, cohort retention and gauges that can exceed full. 7 blocks.
Both drop-offs: from the previous step, and from the top.
src/components/blocks/metrics/metrics-funnel.tsx"use client";
const STAGES = [
{ label: "Visited pricing", value: 18420 },
{ label: "Started trial", value: 4210 },
{ label: "Activated", value: 2680 },
{ label: "Converted", value: 946 },
];
/**
* A funnel that shows both drop-offs.
*
* Two percentages, and the distinction is the point: conversion from the *previous* stage tells
* you where the leak is, conversion from the *top* tells you what it is costing. A funnel showing
* only one of them can be read to mean either, which is how a 22% step-conversion gets reported
* as a 22% funnel.
*
* Widths are relative to the top of the funnel so the taper is proportional; a per-stage scale
* would draw every stage full and remove the shape entirely.
*/
export function MetricsFunnel() {
const top = STAGES[0]!.value;
return (
<ol className="space-y-2">
{STAGES.map((stage, index) => {
const previous = index === 0 ? null : STAGES[index - 1]!.value;
const fromPrevious = previous ? (stage.value / previous) * 100 : 100;
const fromTop = (stage.value / top) * 100;
return (
<li key={stage.label}>
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-medium">{stage.label}</span>
<span className="tabular-nums text-muted-foreground">
{stage.value.toLocaleString("en-US")}
</span>
</div>
{/* The percentage sits BESIDE the bar, not inside it. 10px near-white on
bg-primary/80 measures 3.76:1, under the 4.5:1 floor for normal text, and no
amount of weight fixes that at this size. */}
<div className="mt-1 flex items-center gap-2">
<div className="h-7 flex-1 overflow-hidden rounded-md bg-muted">
<div
className="h-full rounded-md bg-primary/80"
style={{ width: `${fromTop}%` }}
/>
</div>
<span className="w-12 shrink-0 text-end text-[11px] font-semibold tabular-nums">
{fromTop.toFixed(1)}%
</span>
</div>
{previous && (
<p className="mt-1 text-[11px] tabular-nums text-muted-foreground">
{fromPrevious.toFixed(1)}% from previous step ·{" "}
{(previous - stage.value).toLocaleString("en-US")} lost
</p>
)}
</li>
);
})}
</ol>
);
}A triangular heatmap as a real table, with numbers in every cell.
src/components/blocks/metrics/metrics-retention-grid.tsx"use client";
const COHORTS = [
{ cohort: "Apr", size: 420, values: [100, 62, 48, 41, 38] },
{ cohort: "May", size: 512, values: [100, 66, 52, 44, null] },
{ cohort: "Jun", size: 480, values: [100, 61, 47, null, null] },
{ cohort: "Jul", size: 604, values: [100, 68, null, null, null] },
];
/**
* Cohort retention as a real table.
*
* A `<table>`, not a grid of divs, because this *is* tabular data — cohorts down, periods across
* — so row and column headers come for free and a screen reader announces "May, month 2, 52%"
* from one cell. Any div version needs an aria-label per cell to approach that and still loses
* keyboard navigation.
*
* Triangular, because a cohort cannot have data for a month that has not happened. Those cells
* are empty rather than 0%: a zero is a fact about retention, and printing one where there is
* simply no data yet is a lie the reader cannot detect.
*
* Every populated cell prints its number. A heatmap that relies on shade alone is unreadable to
* anyone with reduced colour vision, and unreadable in print.
*/
export function MetricsRetentionGrid() {
const periods = Math.max(...COHORTS.map((row) => row.values.length));
/*
* The tint is capped at 85%, and that cap is load-bearing.
*
* --primary resolves to #007938. Undiluted, it takes near-white text to 5.35:1 but --foreground
* to only 3.49:1; at a 62% tint the two swap, and near-white drops to 2.59:1. Between those
* points there is a narrow band where NEITHER colour clears 4.5:1, so a continuous scale with a
* light/dark threshold cannot be made compliant by moving the threshold — it has to avoid the
* band. Holding the scale at or below 85% keeps every cell light enough for dark text, which is
* then correct for the whole range.
*
* color-mix keeps this on the theme token, so it follows light and dark without a second
* palette — and a continuous scale cannot be expressed as utility classes anyway.
*/
const shade = (value: number) =>
`color-mix(in oklch, var(--primary) ${Math.round((0.1 + (value / 100) * 0.75) * 100)}%, transparent)`;
return (
<div className="overflow-x-auto">
<table className="w-full border-separate border-spacing-0.5 text-xs">
<caption className="sr-only">
Retention by cohort. Rows are the month a cohort joined, columns are months since, and
each cell is the percentage still active.
</caption>
<thead>
<tr>
<th scope="col" className="px-2 py-1 text-start font-medium text-muted-foreground">Cohort</th>
<th scope="col" className="px-2 py-1 text-end font-medium text-muted-foreground">Size</th>
{Array.from({ length: periods }, (_, index) => (
<th key={index} scope="col" className="px-1 py-1 text-center font-medium text-muted-foreground">
M{index}
</th>
))}
</tr>
</thead>
<tbody>
{COHORTS.map((row) => (
<tr key={row.cohort}>
<th scope="row" className="whitespace-nowrap px-2 py-1 text-start font-normal">
{row.cohort}
</th>
<td className="px-2 py-1 text-end tabular-nums text-muted-foreground">{row.size}</td>
{Array.from({ length: periods }, (_, index) => {
const value = row.values[index];
if (value === null || value === undefined) {
return (
<td key={index} className="px-1 py-1 text-center text-muted-foreground/30">
<span aria-hidden="true">–</span>
<span className="sr-only">No data</span>
</td>
);
}
/* One text colour for every cell — see the cap in `shade` above for why. */
return (
<td
key={index}
className="rounded px-1 py-1 text-center font-medium tabular-nums text-foreground"
style={{ backgroundColor: shade(value) }}
>
{value}%
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}Arcs on a 0-110 scale, so over-capacity looks different from full.
src/components/blocks/metrics/metrics-gauge-row.tsx"use client";
import { PolarAngleAxis, RadialBar, RadialBarChart, ResponsiveContainer } from "recharts";
const GAUGES = [
{ label: "Uptime", value: 99.8, tone: "var(--chart-2)" },
{ label: "SLA met", value: 94.2, tone: "var(--chart-1)" },
{ label: "Capacity", value: 104, tone: "var(--destructive)" },
];
/**
* Radial gauges, including one over 100%.
*
* The domain runs to 110, not 100, and that is the whole design decision: an arc that cannot
* exceed its track makes "at capacity" and "over capacity" look identical, which is the one
* reading anyone needs to act on. The printed number is never clamped.
*
* `PolarAngleAxis` with an explicit domain is what makes a RadialBar behave as a gauge rather
* than scaling to whatever the largest value happens to be.
*/
export function MetricsGaugeRow() {
const SCALE = 110;
return (
<div className="grid gap-4 sm:grid-cols-3">
{GAUGES.map((gauge) => (
<div key={gauge.label} className="flex flex-col items-center">
<div className="relative h-32 w-full" aria-hidden="true">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
// Recharts 3 adds tabindex="0" by default. Inside an aria-hidden wrapper that
// makes a focusable element unreachable to assistive tech but still tabbable —
// axe reports it as aria-hidden-focus. The value is in the sr-only text below.
accessibilityLayer={false}
data={[{ value: Math.min(gauge.value, SCALE), fill: gauge.tone }]}
innerRadius="72%"
outerRadius="100%"
startAngle={210}
endAngle={-30}
>
<PolarAngleAxis type="number" domain={[0, SCALE]} tick={false} axisLine={false} />
<RadialBar dataKey="value" cornerRadius={8} background />
</RadialBarChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center pt-4">
<span className="text-xl font-bold tabular-nums">{gauge.value}%</span>
</div>
</div>
<p className="-mt-2 text-sm text-muted-foreground">
{gauge.label}
{/* The value again, in text, because the chart above is aria-hidden. */}
<span className="sr-only">: {gauge.value} percent</span>
</p>
</div>
))}
</div>
);
}Eight trends visible at once, with inverted colour where down is good.
src/components/blocks/metrics/metrics-sparkline-row.tsx"use client";
import { Sparkline } from "@dashboardpack/core/components/charts";
import { cn } from "@dashboardpack/core/lib/utils";
const ROWS = [
{ label: "Sign-ups", value: "1,284", change: 8.4, series: [18, 20, 19, 23, 25, 24, 28, 31] },
{ label: "Activation", value: "62.1%", change: 2.1, series: [55, 57, 56, 58, 59, 61, 60, 62] },
{ label: "Churn", value: "2.4%", change: 1.8, invert: true, series: [1.9, 2.0, 2.1, 2.0, 2.2, 2.3, 2.4] },
{ label: "Avg. session", value: "8m 12s", change: -3.2, series: [9.4, 9.1, 8.9, 8.8, 8.6, 8.4, 8.2] },
];
/**
* A dense row of trends.
*
* For a page that needs eight metrics visible at once, where a card grid would need scrolling.
* The sparkline carries the shape and the number carries the value — neither is enough alone,
* which is why this is not just a table.
*
* `invert` on churn is the detail that matters: without it a rising churn rate renders in the
* positive colour, which reads as reassurance about a number that is getting worse.
*/
export function MetricsSparklineRow() {
return (
<ul className="divide-y divide-border">
{ROWS.map((row) => {
const good = row.invert ? row.change < 0 : row.change > 0;
return (
<li key={row.label} className="flex items-center gap-4 py-3">
<span className="w-28 shrink-0 text-sm text-muted-foreground">{row.label}</span>
<span className="w-20 shrink-0 text-sm font-semibold tabular-nums">{row.value}</span>
<div className="h-8 min-w-0 flex-1">
<Sparkline values={row.series} tone={good ? 2 : 5} height={32} />
</div>
<span
className={cn(
"w-16 shrink-0 text-end text-xs font-medium tabular-nums",
good ? "text-success" : "text-destructive",
)}
>
{row.change > 0 ? "+" : ""}
{row.change}%
</span>
</li>
);
})}
</ul>
);
}One SVG circle each, with pathLength so percentages are literal.
src/components/blocks/metrics/metrics-goal-gauge.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* Radial gauges drawn with one SVG circle and `stroke-dasharray`.
*
* No charting library: an arc is a circle with a dash pattern, and `pathLength={100}` makes the
* dash values literal percentages instead of multiples of 2πr. That removes the radius from the
* arithmetic entirely, so changing the size cannot silently break the fill.
*
* The rotation is applied to the `<svg>` rather than the circle, and it is `-rotate-90` in both
* directions — a gauge starts at twelve o'clock and fills clockwise regardless of writing mode.
* Mirroring it under RTL would make the fill run backwards, which is why this is one of the few
* places a physical transform is correct.
*
* Over-100% is not clamped in the label, only in the arc, so 118% is visible as an achievement
* rather than rounded down to a full ring indistinguishable from exactly meeting the target.
*/
export function MetricsGoalGauge() {
const goals = [
{ label: "New MRR", pct: 118, value: "$23,600", target: "$20,000" },
{ label: "Activation", pct: 74, value: "1,480", target: "2,000" },
{ label: "Retention", pct: 96, value: "91.2%", target: "95%" },
];
return (
<div className="grid gap-4 sm:grid-cols-3">
{goals.map((goal) => {
const complete = goal.pct >= 100;
return (
<Card key={goal.label}>
<CardContent className="flex flex-col items-center gap-3 p-4">
<div className="relative h-28 w-28">
{/* -rotate-90 in BOTH directions: a gauge fills clockwise from twelve o'clock
whatever the writing mode, so this transform must not mirror. */}
<svg viewBox="0 0 40 40" className="h-full w-full -rotate-90">
<circle
cx="20"
cy="20"
r="16"
fill="none"
strokeWidth="4"
className="stroke-muted"
/>
<circle
cx="20"
cy="20"
r="16"
fill="none"
strokeWidth="4"
strokeLinecap="round"
// pathLength=100 makes the dash values plain percentages, so the radius
// never enters the calculation.
pathLength={100}
strokeDasharray={`${Math.min(100, goal.pct)} 100`}
className={cn(complete ? "stroke-success" : "stroke-primary")}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className={cn(
"text-xl font-bold tabular-nums",
complete && "text-success",
)}
>
{goal.pct}%
</span>
<span className="text-[10px] text-muted-foreground">of target</span>
</div>
</div>
<div className="space-y-0.5 text-center">
<p className="text-sm font-medium">{goal.label}</p>
<p className="text-xs tabular-nums text-muted-foreground">
{goal.value} / {goal.target}
</p>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}Shares and totals derived from the rows, so the columns always add up.
src/components/blocks/metrics/metrics-breakdown-table.tsx"use client";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* A contribution table where the bar is drawn *inside* the cell.
*
* The share bar is an absolutely positioned layer behind the number rather than a separate column,
* so the table stays readable at narrow widths where a dedicated chart column would be squeezed to
* nothing. `-z-10` on the bar keeps it under the text without needing a stacking context on every
* cell.
*
* Shares are computed from the values, and the total row is computed from the same array — so the
* percentages sum to 100 and the total matches the rows by construction. Typing a total is how a
* demo table ends up with columns that do not add up.
*
* The header uses `scope="col"` and the label column `scope="row"`, which is what lets a screen
* reader announce "Organic, revenue, $184,200" instead of reading three unrelated cells.
*/
export function MetricsBreakdownTable() {
const rows = [
{ channel: "Organic search", revenue: 184200, orders: 2140 },
{ channel: "Paid search", revenue: 96800, orders: 1180 },
{ channel: "Email", revenue: 48300, orders: 720 },
{ channel: "Social", revenue: 21400, orders: 410 },
{ channel: "Direct", revenue: 18900, orders: 260 },
];
const total = rows.reduce((sum, row) => sum + row.revenue, 0);
const totalOrders = rows.reduce((sum, row) => sum + row.orders, 0);
const money = (value: number) => `$${value.toLocaleString("en-US")}`;
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<caption className="sr-only">Revenue and orders by acquisition channel</caption>
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="px-4 py-2 text-start font-medium">
Channel
</th>
<th scope="col" className="px-4 py-2 text-end font-medium">
Revenue
</th>
<th scope="col" className="px-4 py-2 text-end font-medium">
Share
</th>
<th scope="col" className="px-4 py-2 text-end font-medium">
Orders
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const share = (row.revenue / total) * 100;
return (
<tr key={row.channel} className="border-b last:border-0">
<th scope="row" className="px-4 py-2 text-start font-normal">
{row.channel}
</th>
<td className="px-4 py-2 text-end tabular-nums">{money(row.revenue)}</td>
<td className="relative px-4 py-2 text-end tabular-nums">
{/* The bar sits behind the number rather than in its own column, so it
survives a narrow viewport. */}
<span
aria-hidden="true"
className="absolute inset-y-1 -z-10 rounded bg-primary/15 end-0"
style={{ width: `${share}%` }}
/>
{share.toFixed(1)}%
</td>
<td className="px-4 py-2 text-end tabular-nums">
{row.orders.toLocaleString("en-US")}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className={cn("border-t-2 font-semibold")}>
<th scope="row" className="px-4 py-2 text-start">
Total
</th>
<td className="px-4 py-2 text-end tabular-nums">{money(total)}</td>
<td className="px-4 py-2 text-end tabular-nums">100.0%</td>
<td className="px-4 py-2 text-end tabular-nums">
{totalOrders.toLocaleString("en-US")}
</td>
</tr>
</tfoot>
</table>
</div>
);
}Both rates that matter, with the worst drop-off called out.
src/components/blocks/metrics/metrics-funnel-steps.tsx"use client";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* A conversion funnel showing both rates that matter.
*
* Step-to-step conversion and conversion from the top are different numbers, and a funnel that
* shows only one is misleading either way: 80% step conversion looks healthy until you see it is
* 12% of the original traffic. Both are derived from the counts, so neither can be typed wrong.
*
* The biggest single drop-off is highlighted rather than left to the reader to find by comparing
* five percentages — that comparison is the reason someone opens a funnel.
*/
export function MetricsFunnelSteps() {
const steps = [
{ label: "Visited pricing", count: 24800 },
{ label: "Started signup", count: 8400 },
{ label: "Verified email", count: 6900 },
{ label: "Created workspace", count: 3100 },
{ label: "Invited a teammate", count: 1480 },
];
const top = steps[0]!.count;
const drops = steps.map((step, index) =>
index === 0 ? 0 : 1 - step.count / steps[index - 1]!.count,
);
const worst = drops.indexOf(Math.max(...drops));
return (
<ol className="space-y-2">
{steps.map((step, index) => {
const fromTop = (step.count / top) * 100;
const fromPrevious = index === 0 ? 100 : (step.count / steps[index - 1]!.count) * 100;
const isWorst = index === worst;
return (
<li key={step.label} className="space-y-1.5">
<div className="flex flex-wrap items-baseline gap-2">
<span className="min-w-0 flex-1 text-sm font-medium">{step.label}</span>
<span className="text-sm tabular-nums">{step.count.toLocaleString("en-US")}</span>
<span className="w-16 text-end text-xs tabular-nums text-muted-foreground">
{fromTop.toFixed(1)}%
</span>
</div>
<div className="h-6 overflow-hidden rounded bg-muted">
<div
className={cn(
"flex h-full items-center rounded px-2",
isWorst ? "bg-destructive/25" : "bg-primary/25",
)}
style={{ width: `${fromTop}%` }}
>
{index > 0 && (
<span className="whitespace-nowrap text-[10px] font-medium tabular-nums">
{fromPrevious.toFixed(0)}% of previous
</span>
)}
</div>
</div>
{isWorst && (
<p className="text-xs text-destructive">
Largest drop-off: {((1 - fromPrevious / 100) * 100).toFixed(0)}% lost at this step
</p>
)}
</li>
);
})}
</ol>
);
}