Search for pages, actions, and quick links.
Metric cards, comparison rows and sparkline summaries. 12 blocks.
The standard dashboard opener: four metrics with trend and sparkline.
src/components/blocks/stats/stat-grid-four.tsx"use client";
import { DollarSign, Eye, ShoppingCart, Users } from "lucide-react";
import { StatCard, StatCardGrid } from "@dashboardpack/core/components/charts";
/**
* The four-metric opener.
*
* `tone` indexes the chart palette rather than naming a colour, so the set stays coherent when
* the theme's preset changes — hardcoding `text-emerald-500` here would survive a theme switch
* and clash with everything around it.
*/
export function StatGridFour() {
return (
<StatCardGrid>
<StatCard
index={0}
title="Total revenue"
value="$48,295"
change={12.5}
icon={DollarSign}
tone={1}
sparkline={[31, 34, 33, 38, 36, 41, 44, 43, 48]}
/>
<StatCard
index={1}
title="Active users"
value="2,847"
change={8.2}
icon={Users}
tone={2}
sparkline={[19, 21, 20, 24, 26, 25, 27, 28, 28]}
/>
<StatCard
index={2}
title="Orders"
value="1,432"
change={-3.1}
icon={ShoppingCart}
tone={3}
sparkline={[16, 15, 17, 16, 14, 15, 14, 15, 14]}
/>
<StatCard
index={3}
title="Page views"
value="284K"
change={24.7}
icon={Eye}
tone={4}
sparkline={[18, 20, 22, 21, 24, 25, 27, 26, 28]}
/>
</StatCardGrid>
);
}Burn rate, churn, cost per lead — a fall is an improvement.
src/components/blocks/stats/stat-inverted-trend.tsx"use client";
import { Flame, LogOut, Target } from "lucide-react";
import { StatCard, StatCardGrid } from "@dashboardpack/core/components/charts";
/**
* Metrics where a fall is an improvement.
*
* `invertTrend` is the whole point. Without it a rising churn rate renders in the positive
* colour, which is worse than showing no colour at all — it reads as reassurance about a number
* that is getting worse. Burn rate, churn, attrition, cost per acquisition and average
* resolution time all belong in this group.
*/
export function StatInvertedTrend() {
return (
<StatCardGrid className="sm:grid-cols-3 lg:grid-cols-3">
<StatCard
index={0}
title="Monthly burn"
value="$182K"
change={-6.4}
invertTrend
icon={Flame}
tone={3}
sparkline={[208, 204, 199, 196, 190, 187, 182]}
/>
<StatCard
index={1}
title="Churn rate"
value="2.4%"
change={1.8}
invertTrend
icon={LogOut}
tone={5}
sparkline={[1.9, 2.0, 2.1, 2.0, 2.2, 2.3, 2.4]}
/>
<StatCard
index={2}
title="Cost per lead"
value="$38.20"
change={-11.2}
invertTrend
icon={Target}
tone={2}
sparkline={[47, 45, 44, 42, 41, 39, 38]}
/>
</StatCardGrid>
);
}Six metrics on one line, for a page whose charts need the space.
src/components/blocks/stats/stat-compact-row.tsx"use client";
/**
* Six metrics on one line.
*
* For a page whose charts need the vertical space. No cards, no icons and no sparklines — the
* dividers do the separating, so the row costs about a third of the height of a card grid.
*
* `divide-x` plus `rtl:divide-x-reverse` because a divided row is one of the few places a
* logical property does not exist: Tailwind's divide utilities are physical, and without the
* reverse the dividers land on the wrong side of each cell under RTL.
*/
export function StatCompactRow() {
const metrics = [
{ label: "MRR", value: "$62.4K" },
{ label: "New trials", value: "184" },
{ label: "Conversion", value: "6.2%" },
{ label: "Active seats", value: "3,912" },
{ label: "NPS", value: "48" },
{ label: "Tickets open", value: "27" },
];
return (
<div className="grid grid-cols-2 divide-border rounded-lg border border-border sm:grid-cols-3 sm:divide-x sm:rtl:divide-x-reverse lg:grid-cols-6">
{metrics.map((metric) => (
<div key={metric.label} className="px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{metric.label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{metric.value}</p>
</div>
))}
</div>
);
}A number that only means something next to what it was aiming at.
src/components/blocks/stats/stat-with-target.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* A number next to what it was aiming at.
*
* A figure on its own is rarely readable — $48,295 is good or bad only against the target. The
* bar is capped at 100% so an over-achieving metric does not overflow its track, but the
* *percentage* is not capped: 112% is the interesting case and clamping it would hide it.
*/
export function StatWithTarget() {
const items = [
{ label: "Monthly revenue", value: 48295, target: 55000, prefix: "$" },
{ label: "New customers", value: 847, target: 1000, prefix: "" },
{ label: "Support SLA", value: 112, target: 100, prefix: "", suffix: "%" },
];
return (
<div className="grid gap-4 sm:grid-cols-3">
{items.map((item) => {
const pct = Math.round((item.value / item.target) * 100);
return (
<Card key={item.label}>
<CardContent className="space-y-3 p-4">
<p className="text-sm text-muted-foreground">{item.label}</p>
<div className="flex items-baseline justify-between gap-2">
<p className="text-2xl font-bold tabular-nums">
{item.prefix}
{item.value.toLocaleString("en-US")}
{item.suffix}
</p>
<p
className={cn(
"text-xs font-semibold tabular-nums",
pct >= 100 ? "text-success" : "text-muted-foreground",
)}
>
{pct}%
</p>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", pct >= 100 ? "bg-success" : "bg-primary")}
// Capped so the fill cannot escape its track; the label above is not capped,
// because exceeding the target is the thing worth seeing.
style={{ width: `${Math.min(100, pct)}%` }}
/>
</div>
<p className="text-[11px] text-muted-foreground">
Target {item.prefix}
{item.target.toLocaleString("en-US")}
{item.suffix}
</p>
</CardContent>
</Card>
);
})}
</div>
);
}One headline figure with its components broken out beside it.
src/components/blocks/stats/stat-split-panel.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
/**
* One headline figure with its components broken out beside it.
*
* For a total that is only meaningful once you can see what it is made of — revenue split by
* plan, pipeline split by stage. The parts sum to the headline, which is a constraint worth
* keeping: a breakdown that does not add up is the fastest way to lose a reader's trust.
*/
export function StatSplitPanel() {
const parts = [
{ label: "Subscriptions", value: 31200 },
{ label: "One-off licences", value: 12400 },
{ label: "Support plans", value: 4695 },
];
const total = parts.reduce((sum, part) => sum + part.value, 0);
return (
<Card>
<CardContent className="grid gap-6 p-6 sm:grid-cols-[minmax(0,1fr)_2fr] sm:items-center">
<div>
<p className="text-sm text-muted-foreground">Revenue this month</p>
<p className="mt-1 text-3xl font-bold tabular-nums">
${total.toLocaleString("en-US")}
</p>
<p className="mt-1 text-xs text-success">+12.5% on last month</p>
</div>
<div className="space-y-3">
{parts.map((part) => {
const share = Math.round((part.value / total) * 100);
return (
<div key={part.label} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span>{part.label}</span>
<span className="tabular-nums text-muted-foreground">
${part.value.toLocaleString("en-US")} · {share}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${share}%` }} />
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}Includes the flat case, and never signals direction by colour alone.
src/components/blocks/stats/stat-delta-grid.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { cn } from "@dashboardpack/core/lib/utils";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
/**
* Metrics compared against a named prior period, including the flat case.
*
* Most trend indicators handle up and down and then render 0% with an upward arrow in a success
* colour, which reads as growth. A third state for "no change" costs three lines and removes that
* lie.
*
* The direction is never conveyed by colour alone: each cell carries an arrow glyph *and* an
* `sr-only` word. A red number is invisible as a signal to anyone with a red-green deficiency, and
* WCAG 1.4.1 is explicit that colour cannot be the only means of conveying information.
*/
export function StatDeltaGrid() {
const metrics = [
{ label: "Sessions", value: "48,291", delta: 12.4 },
{ label: "Conversion", value: "3.28%", delta: -0.6 },
{ label: "Avg order", value: "$84.20", delta: 0 },
{ label: "Refund rate", value: "1.9%", delta: -0.4, invert: true },
];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => {
const flat = metric.delta === 0;
// `invert` marks metrics where a fall is the good outcome — refunds, churn, latency.
const good = metric.invert ? metric.delta < 0 : metric.delta > 0;
const Icon = flat ? Minus : metric.delta > 0 ? ArrowUp : ArrowDown;
const word = flat ? "no change" : metric.delta > 0 ? "up" : "down";
return (
<Card key={metric.label}>
<CardContent className="space-y-2 p-4">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<p className="text-2xl font-bold tabular-nums">{metric.value}</p>
<div
className={cn(
"flex items-center gap-1 text-xs font-medium",
flat ? "text-muted-foreground" : good ? "text-success" : "text-destructive",
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="tabular-nums">{Math.abs(metric.delta)}%</span>
<span className="sr-only">{word}</span>
<span className="font-normal text-muted-foreground">vs last month</span>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}Switched rather than stacked, with figures that stay internally consistent.
src/components/blocks/stats/stat-period-compare.tsx"use client";
import { useState } from "react";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* One metric across three period lengths, switched rather than stacked.
*
* A dashboard that shows "revenue this month" invites the question "compared to what", and stacking
* 7/30/90-day cards triples the space to answer it. Switching keeps one card and makes the
* comparison explicit.
*
* The period control is a `radiogroup`, since exactly one range applies. Rendering three
* `aria-pressed` toggles would let assistive tech report that several ranges are active at once,
* which is not a state this component can be in.
*
* Absolute values are recomputed per period rather than scaled from a single number, so the figures
* stay internally consistent — a 90-day total that is not 3× the 30-day one is the realistic case,
* and faking it with multiplication produces numbers nobody would believe.
*/
export function StatPeriodCompare() {
const periods = {
"7d": { label: "7 days", revenue: "$11,480", orders: 312, prior: 8.2 },
"30d": { label: "30 days", revenue: "$48,295", orders: 1284, prior: 12.4 },
"90d": { label: "90 days", revenue: "$132,910", orders: 3641, prior: -2.8 },
} as const;
const [period, setPeriod] = useState<keyof typeof periods>("30d");
const current = periods[period];
return (
<Card>
<CardContent className="space-y-4 p-4 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">Revenue</p>
<div
role="radiogroup"
aria-label="Period"
className="inline-flex rounded-md bg-muted p-0.5"
>
{(Object.keys(periods) as (keyof typeof periods)[]).map((key) => {
const active = key === period;
return (
<button
key={key}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setPeriod(key)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{periods[key].label}
</button>
);
})}
</div>
</div>
<p className="text-3xl font-bold tabular-nums">{current.revenue}</p>
<dl className="grid grid-cols-2 gap-4 border-t pt-3">
<div>
<dt className="text-xs text-muted-foreground">Orders</dt>
<dd className="text-sm font-semibold tabular-nums">
{current.orders.toLocaleString("en-US")}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">vs previous {current.label}</dt>
<dd
className={cn(
"text-sm font-semibold tabular-nums",
current.prior > 0 ? "text-success" : "text-destructive",
)}
>
{current.prior > 0 ? "+" : ""}
{current.prior}%
</dd>
</div>
</dl>
</CardContent>
</Card>
);
}Shaped like the content it replaces, so nothing jumps when data lands.
src/components/blocks/stats/stat-loading-skeleton.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { Skeleton } from "@dashboardpack/core/components/ui/skeleton";
/**
* The loading state for a metric row, shaped like the content it replaces.
*
* The point is that the skeleton occupies the same box as the loaded card — same padding, same
* three lines, same heights. A skeleton that is a different size causes the page to jump when data
* arrives, which is worse than showing nothing.
*
* `aria-busy` on the container plus a single `sr-only` "Loading metrics" is the whole accessibility
* story. The alternative — leaving four pulsing grey boxes unlabelled — announces nothing, so a
* screen-reader user hears an empty region and assumes the page is broken.
*
* Toggle it to compare the two states directly; that comparison is the reason this ships as a block
* rather than as a paragraph in the docs.
*/
export function StatLoadingSkeleton() {
const [loading, setLoading] = useState(true);
const loaded = [
{ label: "Revenue", value: "$48,295" },
{ label: "Orders", value: "1,284" },
{ label: "Customers", value: "412" },
{ label: "Churn", value: "2.1%" },
];
return (
<div className="space-y-3">
<Button variant="outline" size="sm" onClick={() => setLoading((prev) => !prev)}>
{loading ? "Show loaded state" : "Show loading state"}
</Button>
<div
aria-busy={loading}
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{loading && <span className="sr-only">Loading metrics</span>}
{loaded.map((metric, index) => (
<Card key={metric.label}>
<CardContent className="space-y-2 p-4">
{loading ? (
<>
{/* Heights match the real text below: 20px label, 32px value, 16px trend. */}
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-20" />
</>
) : (
<>
<p className="flex h-5 items-center text-sm text-muted-foreground">
{metric.label}
</p>
<p className="flex h-8 items-center text-2xl font-bold tabular-nums">
{metric.value}
</p>
<p className="flex h-4 items-center text-xs text-muted-foreground">
+{(index + 1) * 3}.2% vs last month
</p>
</>
)}
</CardContent>
</Card>
))}
</div>
</div>
);
}Breadth over depth, with a literal-class tone map Tailwind can scan.
src/components/blocks/stats/stat-icon-tiles.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { Boxes, CreditCard, Headset, RefreshCcw, Truck, Users } from "lucide-react";
/**
* Six metrics as icon tiles, for a page that needs breadth over depth.
*
* The tone map is a `Record` of literal class strings. Tailwind's scanner reads source text, so
* `bg-chart-${n}/10` would emit nothing and every tile would come out unstyled — the same
* constraint the StatCard tone map documents.
*/
export function StatIconTiles() {
const tiles = [
{ label: "Orders", value: "1,284", Icon: Boxes, tone: "bg-chart-1/10 text-chart-1" },
{ label: "Customers", value: "412", Icon: Users, tone: "bg-chart-2/10 text-chart-2" },
{ label: "Revenue", value: "$48.3k", Icon: CreditCard, tone: "bg-chart-3/10 text-chart-3" },
{ label: "Shipments", value: "968", Icon: Truck, tone: "bg-chart-4/10 text-chart-4" },
{ label: "Returns", value: "38", Icon: RefreshCcw, tone: "bg-chart-5/10 text-chart-5" },
{ label: "Tickets", value: "24", Icon: Headset, tone: "bg-primary/10 text-primary" },
];
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{tiles.map((tile) => (
<Card key={tile.label}>
<CardContent className="flex flex-col items-start gap-2 p-3">
<span
className={`flex h-8 w-8 items-center justify-center rounded-md ${tile.tone}`}
>
<tile.Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="space-y-0.5">
<p className="text-lg font-bold tabular-nums leading-none">{tile.value}</p>
<p className="text-xs text-muted-foreground">{tile.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}Paired bars, so a big percentage on a tiny base is visible.
src/components/blocks/stats/stat-versus-columns.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* This period against last, as paired bars rather than a percentage.
*
* A single "+12.4%" hides whether the base was 10 or 10,000. Showing both magnitudes makes a large
* relative change on a tiny base obvious, which is the most common way a KPI card misleads.
*
* Both bars scale against the larger of the pair, so the taller one always fills the track and the
* comparison is readable per row without a shared axis across rows of different units.
*/
export function StatVersusColumns() {
const metrics = [
{ label: "Revenue", now: 48295, then: 42960, unit: "$" },
{ label: "Orders", now: 1284, then: 1361, unit: "" },
{ label: "New customers", now: 412, then: 288, unit: "" },
];
return (
<div className="grid gap-4 sm:grid-cols-3">
{metrics.map((metric) => {
const max = Math.max(metric.now, metric.then);
const up = metric.now >= metric.then;
const delta = ((metric.now - metric.then) / metric.then) * 100;
return (
<Card key={metric.label}>
<CardContent className="space-y-3 p-4">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm text-muted-foreground">{metric.label}</p>
<p
className={cn(
"text-xs font-semibold tabular-nums",
up ? "text-success" : "text-destructive",
)}
>
{up ? "+" : ""}
{delta.toFixed(1)}%
</p>
</div>
<div className="flex items-end gap-3">
{[
{ caption: "This month", value: metric.now, tone: "bg-primary" },
{ caption: "Last month", value: metric.then, tone: "bg-muted-foreground/30" },
].map((bar) => (
<div key={bar.caption} className="flex-1 space-y-1">
<div className="flex h-16 items-end">
<div
className={cn("w-full rounded-t", bar.tone)}
style={{ height: `${(bar.value / max) * 100}%` }}
/>
</div>
<p className="text-xs font-medium tabular-nums">
{metric.unit}
{bar.value.toLocaleString("en-US")}
</p>
<p className="text-[10px] text-muted-foreground">{bar.caption}</p>
</div>
))}
</div>
</CardContent>
</Card>
);
})}
</div>
);
}Four SVG rings with pathLength, so the radius leaves the maths.
src/components/blocks/stats/stat-ring-row.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
/**
* Four completion rings in a row, each an SVG circle with `pathLength={100}`.
*
* `pathLength` makes the dash values literal percentages, so the radius drops out of the
* arithmetic — resizing the ring cannot silently break the fill. `-rotate-90` is applied in both
* directions on purpose: a ring fills clockwise from twelve o'clock whatever the writing mode, so
* mirroring it under RTL would run the fill backwards.
*/
export function StatRingRow() {
const rings = [
{ label: "Profile", pct: 100, tone: "stroke-success" },
{ label: "Billing", pct: 75, tone: "stroke-chart-1" },
{ label: "Team", pct: 50, tone: "stroke-chart-3" },
{ label: "Integrations", pct: 20, tone: "stroke-chart-4" },
];
return (
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-4 sm:grid-cols-4">
{rings.map((ring) => (
<div key={ring.label} className="flex flex-col items-center gap-2">
<div className="relative h-20 w-20">
<svg viewBox="0 0 40 40" className="h-full w-full -rotate-90">
<circle cx="20" cy="20" r="16" fill="none" strokeWidth="3.5" className="stroke-muted" />
<circle
cx="20"
cy="20"
r="16"
fill="none"
strokeWidth="3.5"
strokeLinecap="round"
pathLength={100}
strokeDasharray={`${ring.pct} 100`}
className={ring.tone}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold tabular-nums">
{ring.pct}%
</span>
</div>
<p className="text-xs text-muted-foreground">{ring.label}</p>
</div>
))}
</CardContent>
</Card>
);
}Jumps straight to the value under prefers-reduced-motion.
src/components/blocks/stats/stat-live-counter.tsx"use client";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
/**
* Reads `prefers-reduced-motion` as a subscription rather than in an effect.
*
* The obvious version — `useEffect(() => { if (matches) setProgress(1) })` — calls setState
* synchronously during an effect, which React's lint rule flags because it triggers a second render
* pass before paint. Reading it through a store instead means the value is available *during*
* render, so the animation simply never starts rather than being started and cancelled.
*
* The server snapshot is `false`, matching what the static export prerenders, so hydration agrees
* and the flip happens on the commit after.
*/
function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
useCallback((onChange: () => void) => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []),
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* A count-up that skips the animation under `prefers-reduced-motion`.
*
* Numbers rolling upward are exactly the continuous motion the setting exists to suppress, so the
* reduced case renders the final value with no interval at all.
*
* The interval is cleared on unmount — this block can be torn down mid-count when its lazy preview
* scrolls out of range, and an uncleared timer then sets state on an unmounted component.
*/
export function StatLiveCounter() {
const targets = [
{ label: "Active sessions", value: 1284, prefix: "" },
{ label: "Revenue today", value: 8420, prefix: "$" },
{ label: "Signups this hour", value: 37, prefix: "" },
];
const reduced = usePrefersReducedMotion();
const [progress, setProgress] = useState(0);
useEffect(() => {
if (reduced) return;
const timer = setInterval(() => {
setProgress((previous) => {
const next = previous + 0.04;
if (next >= 1) {
clearInterval(timer);
return 1;
}
return next;
});
}, 24);
return () => clearInterval(timer);
}, [reduced]);
// Derived during render, so no effect has to write it.
const shown = reduced ? 1 : progress;
return (
<div className="grid gap-4 sm:grid-cols-3">
{targets.map((target) => (
<Card key={target.label}>
<CardContent className="space-y-1 p-4">
<p className="text-sm text-muted-foreground">{target.label}</p>
<p className="text-2xl font-bold tabular-nums">
{target.prefix}
{Math.round(target.value * shown).toLocaleString("en-US")}
</p>
</CardContent>
</Card>
))}
</div>
);
}