Search for pages, actions, and quick links.
Chart cards built on the shared Recharts primitives. 10 blocks.
One chart, several series, switched rather than stacked.
src/components/blocks/charts/chart-area-trend.tsx"use client";
import * as React from "react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
ChartCard,
ChartTooltip,
SegmentedControl,
areaSeriesProps,
compactCurrency,
gridProps,
useChartId,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
const DATA = [
{ month: "Jan", revenue: 31200, orders: 820, profit: 9100 },
{ month: "Feb", revenue: 33800, orders: 910, profit: 10400 },
{ month: "Mar", revenue: 32400, orders: 870, profit: 9800 },
{ month: "Apr", revenue: 37600, orders: 1010, profit: 12200 },
{ month: "May", revenue: 36100, orders: 960, profit: 11500 },
{ month: "Jun", revenue: 41900, orders: 1120, profit: 14100 },
{ month: "Jul", revenue: 44300, orders: 1180, profit: 15200 },
{ month: "Aug", revenue: 43100, orders: 1150, profit: 14700 },
{ month: "Sep", revenue: 48295, orders: 1290, profit: 16800 },
];
const SERIES = [
{ key: "revenue", label: "Revenue" },
{ key: "orders", label: "Orders" },
{ key: "profit", label: "Profit" },
] as const;
/**
* One chart, several series, switched rather than stacked.
*
* `useChartId` is not optional decoration. An SVG `url(#id)` reference resolves to the first
* match in the *document*, not in this component's subtree — so two copies of this block on one
* page with a hardcoded gradient id would both paint with whichever gradient rendered first.
* The catalog page renders many blocks together, which is exactly that situation.
*/
export function ChartAreaTrend() {
const [series, setSeries] = React.useState<string>("revenue");
const gradientId = useChartId("area-trend");
const active = SERIES.find((entry) => entry.key === series) ?? SERIES[0];
return (
<ChartCard
title="Performance"
subtitle="Monthly, current year"
height={260}
action={
<SegmentedControl
options={SERIES.map((entry) => ({ label: entry.label, value: entry.key }))}
value={series}
onChange={(next) => setSeries(next ?? "revenue")}
label="Series"
/>
}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={DATA}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis dataKey="month" {...xAxisProps} />
<YAxis {...yAxisProps} tickFormatter={(v) => compactCurrency(Number(v))} />
<Tooltip content={<ChartTooltip format={compactCurrency} />} />
<Area
{...areaSeriesProps}
dataKey={active.key}
name={active.label}
stroke="var(--chart-1)"
fill={`url(#${gradientId})`}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
);
}How a total divides, over time.
src/components/blocks/charts/chart-stacked-bars.tsx"use client";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
ChartCard,
ChartTooltip,
barSeriesProps,
count,
gridProps,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
const DATA = [
{ week: "W1", direct: 240, organic: 180, referral: 90, social: 60 },
{ week: "W2", direct: 260, organic: 195, referral: 105, social: 72 },
{ week: "W3", direct: 235, organic: 205, referral: 98, social: 81 },
{ week: "W4", direct: 288, organic: 224, referral: 120, social: 95 },
{ week: "W5", direct: 301, organic: 240, referral: 112, social: 88 },
{ week: "W6", direct: 322, organic: 258, referral: 134, social: 104 },
];
const SERIES = [
{ key: "direct", label: "Direct", colour: "var(--chart-1)" },
{ key: "organic", label: "Organic", colour: "var(--chart-2)" },
{ key: "referral", label: "Referral", colour: "var(--chart-3)" },
{ key: "social", label: "Social", colour: "var(--chart-4)" },
];
/**
* How a total divides, over time.
*
* `stackId` on every series is what makes it a composition rather than four overlapping bars.
* Only the last series in the stack gets a rounded top: rounding them all would put a curve in
* the middle of the column where two segments meet.
*/
export function ChartStackedBars() {
return (
<ChartCard title="Sessions by channel" subtitle="Last six weeks" height={260}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={DATA}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="week" {...xAxisProps} />
<YAxis {...yAxisProps} tickFormatter={(v) => count(Number(v))} />
<Tooltip content={<ChartTooltip format={count} />} />
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12 }} />
{SERIES.map((entry, index) => (
<Bar
{...barSeriesProps}
key={entry.key}
dataKey={entry.key}
name={entry.label}
stackId="channels"
fill={entry.colour}
radius={index === SERIES.length - 1 ? [4, 4, 0, 0] : 0}
/>
))}
</BarChart>
</ResponsiveContainer>
</ChartCard>
);
}A breakdown where the legend carries the numbers, not the slices.
src/components/blocks/charts/chart-donut-legend.tsx"use client";
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
import { ChartCard } from "@dashboardpack/core/components/charts";
const DATA = [
{ label: "Direct", value: 35, colour: "var(--chart-1)" },
{ label: "Organic", value: 28, colour: "var(--chart-2)" },
{ label: "Referral", value: 22, colour: "var(--chart-3)" },
{ label: "Social", value: 15, colour: "var(--chart-4)" },
];
/**
* A breakdown where the legend carries the numbers.
*
* Slice labels on a donut are unreadable below about 8% and overlap each other besides, so the
* figures live in a list beside the chart. That list is also the accessible version: the chart
* is `aria-hidden`, because a screen reader gets nothing from a pie and everything from the rows.
*/
export function ChartDonutLegend() {
const total = DATA.reduce((sum, entry) => sum + entry.value, 0);
return (
<ChartCard title="Traffic sources" subtitle="Share of sessions" height={220}>
<div className="flex h-full flex-col items-center gap-6 sm:flex-row">
<div className="relative h-full w-full max-w-[200px] shrink-0" aria-hidden="true">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={DATA}
dataKey="value"
nameKey="label"
innerRadius="62%"
outerRadius="92%"
paddingAngle={2}
strokeWidth={0}
>
{DATA.map((entry) => (
<Cell key={entry.label} fill={entry.colour} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-bold tabular-nums">{total}%</span>
<span className="text-[10px] text-muted-foreground">tracked</span>
</div>
</div>
<ul className="w-full space-y-2.5">
{DATA.map((entry) => (
<li key={entry.label} className="flex items-center gap-2.5 text-sm">
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: entry.colour }}
aria-hidden="true"
/>
<span className="flex-1 text-muted-foreground">{entry.label}</span>
<span className="font-semibold tabular-nums">{entry.value}%</span>
</li>
))}
</ul>
</div>
</ChartCard>
);
}Two series in different units, with the axis colours to tell them apart.
src/components/blocks/charts/chart-dual-axis.tsx"use client";
import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
ChartCard,
ChartTooltip,
barSeriesProps,
compactCurrency,
gridProps,
lineSeriesProps,
percent,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
const DATA = [
{ month: "Apr", revenue: 37600, margin: 32.4 },
{ month: "May", revenue: 36100, margin: 31.8 },
{ month: "Jun", revenue: 41900, margin: 33.6 },
{ month: "Jul", revenue: 44300, margin: 34.3 },
{ month: "Aug", revenue: 43100, margin: 34.1 },
{ month: "Sep", revenue: 48295, margin: 34.8 },
];
/**
* Two series in different units.
*
* A dual axis is easy to make unreadable. Two things keep it honest here: each axis is tinted
* with its own series' colour, so there is no guessing which scale a line belongs to, and the
* two series use different marks — bars for the amount, a line for the rate — because two lines
* on two scales invite a comparison of slopes that means nothing.
*
* `orientation="right"` is physical, not logical, and there is no logical alternative: Recharts
* lays out in pixels and its axis API accepts only "left" or "right". So the secondary axis stays
* on the right under RTL while the rest of the page mirrors. That is one of the documented RTL
* limitations of chart internals rather than something to work around here — the alternative is
* swapping the axes in JavaScript on direction change, which moves the *data* to a different
* scale and is far more confusing than an axis on the unexpected side.
*/
export function ChartDualAxis() {
return (
<ChartCard title="Revenue and margin" subtitle="Six months" height={260}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={DATA}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="month" {...xAxisProps} />
<YAxis
{...yAxisProps}
yAxisId="amount"
tickFormatter={(v) => compactCurrency(Number(v))}
tick={{ fontSize: 11, fill: "var(--chart-1)" }}
/>
<YAxis
{...yAxisProps}
yAxisId="rate"
orientation="right"
domain={[0, 50]}
tickFormatter={(v) => percent(Number(v))}
tick={{ fontSize: 11, fill: "var(--chart-4)" }}
/>
<Tooltip
content={
<ChartTooltip
// Branches on the series NAME, which is what a tooltip point carries — there
// is no dataKey on it. Two series in different units need two formats, or the
// margin reads as $34.80.
format={(value, point) =>
point?.name === "Margin" ? percent(value) : compactCurrency(value)
}
/>
}
/>
<Bar
{...barSeriesProps}
yAxisId="amount"
dataKey="revenue"
name="Revenue"
fill="var(--chart-1)"
radius={[4, 4, 0, 0]}
/>
<Line
{...lineSeriesProps}
yAxisId="rate"
dataKey="margin"
name="Margin"
stroke="var(--chart-4)"
/>
</ComposedChart>
</ResponsiveContainer>
</ChartCard>
);
}A series read against the range it is supposed to stay inside.
src/components/blocks/charts/chart-reference-band.tsx"use client";
import {
CartesianGrid,
Line,
LineChart,
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
ChartCard,
ChartTooltip,
count,
gridProps,
lineSeriesProps,
withSuffix,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
const DATA = [
{ day: "Mon", p95: 780 },
{ day: "Tue", p95: 820 },
{ day: "Wed", p95: 910 },
{ day: "Thu", p95: 1180 },
{ day: "Fri", p95: 1420 },
{ day: "Sat", p95: 940 },
{ day: "Sun", p95: 860 },
];
// withSuffix wraps a formatter; it does not create one from a bare suffix.
const ms = withSuffix(count, "ms");
/**
* A series read against the range it is supposed to stay inside.
*
* The band is the point: latency of 1,180ms means nothing until you know the budget was 1,000.
* `ReferenceArea` is drawn before the line so the line paints over it, and the band uses a
* low-opacity fill rather than a solid one — a solid block would read as data.
*/
export function ChartReferenceBand() {
return (
<ChartCard title="Response time" subtitle="p95, against a 1,000ms budget" height={260}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={DATA}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="day" {...xAxisProps} />
<YAxis {...yAxisProps} domain={[0, 1600]} tickFormatter={(v) => ms(Number(v))} />
<ReferenceArea y1={0} y2={1000} fill="var(--success)" fillOpacity={0.07} />
<ReferenceLine
y={1000}
stroke="var(--warning)"
strokeDasharray="4 4"
label={{ value: "Budget", position: "insideTopRight", fontSize: 11, fill: "var(--warning)" }}
/>
<Tooltip content={<ChartTooltip format={ms} />} />
<Line {...lineSeriesProps} dataKey="p95" name="p95" stroke="var(--chart-2)" />
</LineChart>
</ResponsiveContainer>
</ChartCard>
);
}Replaces the plot area at the same height, and says what would fill it.
src/components/blocks/charts/chart-empty-state.tsx"use client";
import { useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
ChartCard,
ChartTooltip,
count,
gridProps,
lineSeriesProps,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
import { Button } from "@dashboardpack/core/components/ui/button";
import { LineChart as LineChartIcon } from "lucide-react";
const DATA = [
{ week: "W1", signups: 42 },
{ week: "W2", signups: 61 },
{ week: "W3", signups: 55 },
{ week: "W4", signups: 78 },
];
/**
* What a chart card shows when there is nothing to plot.
*
* Recharts renders an empty grid and empty axes for an empty dataset, which looks like a chart that
* failed to load rather than a chart with no data. So the empty case is not a chart at all — it
* replaces the plot area with a message at the same height.
*
* Matching the height is the point. Swapping a 240px chart for a 60px message makes the whole page
* reflow the moment data arrives, and on a dashboard of eight cards that is a visible jolt.
*
* The empty state says what would cause data to appear, rather than "No data". A chart that has
* never had data and a chart filtered down to nothing need different sentences, and neither of them
* is "No data available".
*/
export function ChartEmptyState() {
const [empty, setEmpty] = useState(true);
return (
<div className="space-y-3">
<Button variant="outline" size="sm" onClick={() => setEmpty((prev) => !prev)}>
{empty ? "Show with data" : "Show empty"}
</Button>
{/* ChartCard owns the plot area's height, so both branches occupy the same box and
nothing reflows when data arrives. */}
<ChartCard title="Weekly signups" subtitle="New accounts per week" height={240}>
{empty ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-muted text-muted-foreground">
<LineChartIcon className="h-5 w-5" aria-hidden="true" />
</span>
<div className="space-y-1">
<p className="text-sm font-medium">No signups in this range</p>
<p className="text-sm text-muted-foreground">
Widen the date range, or clear the channel filter to include organic traffic.
</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={DATA} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="week" {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<ChartTooltip format={count} />} />
<Line
{...lineSeriesProps}
dataKey="signups"
name="Signups"
stroke="var(--chart-1)"
/>
</LineChart>
</ResponsiveContainer>
)}
</ChartCard>
</div>
);
}The one chart form RTL does not fix for free, fixed here.
src/components/blocks/charts/chart-horizontal-bars.tsx"use client";
import { useCallback, useSyncExternalStore } from "react";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
ChartCard,
ChartTooltip,
barSeriesProps,
compact,
verticalGridProps,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
const DATA = [
{ source: "Organic search", visits: 48200 },
{ source: "Paid search", visits: 31400 },
{ source: "Direct", visits: 22800 },
{ source: "Referral", visits: 14100 },
{ source: "Social", visits: 8600 },
];
/**
* Reads `<html dir>` and re-renders when it changes.
*
* Deliberately local to this block rather than imported: a copy-paste block should work in a
* customer's project without dragging a hook along with it. The server snapshot is `"ltr"`, which
* is what the static export prerenders, so the first client render matches and there is no
* hydration mismatch — the flip happens on the commit after.
*/
function useHtmlDirection(): "ltr" | "rtl" {
return useSyncExternalStore(
useCallback((onChange: () => void) => {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["dir"] });
return () => observer.disconnect();
}, []),
() => (document.documentElement.dir === "rtl" ? "rtl" : "ltr"),
() => "ltr" as const,
);
}
/**
* A horizontal bar chart that actually mirrors under RTL.
*
* This is the limitation the RTL docs page calls out. Recharts lays out in pixels, so a
* `layout="vertical"` chart grows left-to-right and puts the category axis on the left regardless of
* `dir` — the one chart form that does not mirror for free.
*
* `reversed` on the value axis and `orientation` on the category axis are what flip it. Nothing in
* CSS can: the bars are SVG rects positioned by the library.
*/
export function ChartHorizontalBars() {
const rtl = useHtmlDirection() === "rtl";
return (
<ChartCard title="Traffic by source" subtitle="Visits, last 30 days" height={260}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={DATA} layout="vertical" margin={{ top: 4, right: 12, bottom: 0, left: 12 }}>
<CartesianGrid {...verticalGridProps} />
<XAxis type="number" reversed={rtl} tickFormatter={(v) => compact(Number(v))} {...xAxisProps} />
<YAxis
type="category"
dataKey="source"
width={110}
orientation={rtl ? "right" : "left"}
{...yAxisProps}
/>
<Tooltip content={<ChartTooltip format={compact} />} />
<Bar dataKey="visits" name="Visits" fill="var(--chart-1)" {...barSeriesProps} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
);
}Normalised in the data, so the axis and tooltip agree.
src/components/blocks/charts/chart-percent-stack.tsx"use client";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
ChartCard,
ChartTooltip,
gridProps,
percent,
xAxisProps,
yAxisProps,
} from "@dashboardpack/core/components/charts";
const RAW = [
{ month: "Feb", free: 620, pro: 240, enterprise: 40 },
{ month: "Mar", free: 640, pro: 290, enterprise: 52 },
{ month: "Apr", free: 610, pro: 350, enterprise: 61 },
{ month: "May", free: 580, pro: 420, enterprise: 78 },
{ month: "Jun", free: 540, pro: 480, enterprise: 96 },
{ month: "Jul", free: 505, pro: 540, enterprise: 118 },
];
/**
* A 100%-stacked area, normalised in the data rather than by the chart.
*
* Recharts has `stackOffset="expand"`, which normalises for the *drawing* but leaves the raw counts
* in the tooltip — so the chart shows 46% while the tooltip says 505, and the reader has to work out
* that they describe the same thing. Converting to shares up front makes the axis, the areas and the
* tooltip all agree.
*
* The trade-off is losing the absolute totals, so the subtitle carries them. A share chart with no
* magnitude anywhere cannot tell growth from redistribution.
*/
const DATA = RAW.map((row) => {
const total = row.free + row.pro + row.enterprise;
return {
month: row.month,
total,
free: (row.free / total) * 100,
pro: (row.pro / total) * 100,
enterprise: (row.enterprise / total) * 100,
};
});
export function ChartPercentStack() {
const latest = DATA[DATA.length - 1]!;
return (
<ChartCard
title="Plan mix"
subtitle={`Share of ${latest.total.toLocaleString("en-US")} accounts`}
height={260}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={DATA} margin={{ top: 8, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid {...gridProps} />
<XAxis dataKey="month" {...xAxisProps} />
<YAxis domain={[0, 100]} tickFormatter={(v) => percent(Number(v))} {...yAxisProps} />
<Tooltip content={<ChartTooltip format={percent} />} />
{[
{ key: "free", name: "Free", tone: "var(--chart-3)" },
{ key: "pro", name: "Pro", tone: "var(--chart-1)" },
{ key: "enterprise", name: "Enterprise", tone: "var(--chart-2)" },
].map((series) => (
<Area
key={series.key}
type="monotone"
dataKey={series.key}
name={series.name}
stackId="mix"
stroke={series.tone}
fill={series.tone}
fillOpacity={0.25}
strokeWidth={2}
dot={false}
/>
))}
</AreaChart>
</ResponsiveContainer>
</ChartCard>
);
}A fixed radius domain, so two renders stay comparable.
src/components/blocks/charts/chart-radar-profile.tsx"use client";
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
import { ChartCard, ChartTooltip, count } from "@dashboardpack/core/components/charts";
const DATA = [
{ axis: "Delivery", team: 82, benchmark: 70 },
{ axis: "Quality", team: 91, benchmark: 74 },
{ axis: "Velocity", team: 64, benchmark: 72 },
{ axis: "Collaboration", team: 88, benchmark: 68 },
{ axis: "Documentation", team: 47, benchmark: 65 },
{ axis: "On-call", team: 76, benchmark: 71 },
];
/**
* A radar with a benchmark ring behind the measured series.
*
* A lone radar shape says nothing — the whole value is the comparison, so the benchmark is drawn
* first and unfilled, letting the team's shape read as inside or outside it per axis. Documentation
* dipping under the benchmark is the point of the chart.
*
* The radius axis is fixed to `[0, 100]` rather than auto-scaled. Auto-scaling a radar means the
* shape changes when the data changes even if nothing crossed the benchmark, which makes two
* screenshots incomparable.
*/
export function ChartRadarProfile() {
return (
<ChartCard title="Team scorecard" subtitle="Against the org benchmark" height={280}>
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={DATA} outerRadius="72%">
<PolarGrid stroke="var(--border)" />
<PolarAngleAxis dataKey="axis" tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} />
{/* Fixed domain, so the shape is comparable between renders. */}
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
<Tooltip content={<ChartTooltip format={count} />} />
<Radar
name="Benchmark"
dataKey="benchmark"
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
fill="none"
/>
<Radar
name="This team"
dataKey="team"
stroke="var(--chart-1)"
fill="var(--chart-1)"
fillOpacity={0.25}
/>
</RadarChart>
</ResponsiveContainer>
</ChartCard>
);
}Unique gradient ids and no tab stops, at density.
src/components/blocks/charts/chart-sparkline-grid.tsx"use client";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { Sparkline } from "@dashboardpack/core/components/charts";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* Eight trends at a glance, one sparkline each.
*
* `Sparkline` calls `useChartId()` internally, which is what makes this density safe: an SVG
* `url(#id)` resolves to the first match in the *document*, so eight tiles sharing a hardcoded
* gradient id would all render in the first tile's colour. `useId()` behind the primitive means the
* caller cannot get that wrong.
*
* It also sets `accessibilityLayer={false}`. Recharts 3 adds `tabindex="0"` to every chart by
* default, so eight of these would put eight stops in the tab order for graphics carrying nothing a
* keyboard user can act on — the value and delta beside each one are the accessible content.
*
* `invert` marks series where a fall is good, so latency dropping reads green while sessions
* dropping reads red. Colour follows meaning, not direction.
*/
export function ChartSparklineGrid() {
const series = [
{ label: "Revenue", value: "$48.3k", delta: 12.4, tone: 1 as const, data: [31, 34, 33, 38, 42, 44, 48] },
{ label: "Orders", value: "1,284", delta: 6.1, tone: 1 as const, data: [980, 1020, 1110, 1090, 1180, 1240, 1284] },
{ label: "Sessions", value: "92.4k", delta: -2.8, tone: 5 as const, data: [98, 97, 95, 96, 94, 93, 92] },
{ label: "Conversion", value: "3.28%", delta: 0.4, tone: 2 as const, data: [2.9, 3.0, 3.1, 3.0, 3.2, 3.2, 3.3] },
{ label: "p95 latency", value: "412ms", delta: -18.2, invert: true, tone: 2 as const, data: [720, 690, 640, 580, 500, 450, 412] },
{ label: "Error rate", value: "0.14%", delta: -0.06, invert: true, tone: 2 as const, data: [0.3, 0.28, 0.24, 0.2, 0.18, 0.16, 0.14] },
{ label: "Churn", value: "2.1%", delta: 0.1, invert: true, tone: 5 as const, data: [1.8, 1.9, 1.9, 2.0, 2.0, 2.0, 2.1] },
{ label: "NPS", value: "48", delta: 4, tone: 3 as const, data: [38, 40, 41, 43, 44, 46, 48] },
];
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{series.map((item) => {
const good = item.invert ? item.delta < 0 : item.delta > 0;
return (
<Card key={item.label}>
<CardContent className="space-y-2 p-3">
<p className="text-xs text-muted-foreground">{item.label}</p>
<div className="flex items-baseline justify-between gap-2">
<p className="text-base font-bold tabular-nums">{item.value}</p>
<p
className={cn(
"text-[11px] font-semibold tabular-nums",
good ? "text-success" : "text-destructive",
)}
>
{item.delta > 0 ? "+" : ""}
{item.delta}
</p>
</div>
<Sparkline values={item.data} tone={item.tone} height={28} />
</CardContent>
</Card>
);
})}
</div>
);
}