Chart Stat Combo
Original · freestat cards each with a mini chart
byWeb InnoventixReact + Tailwind
chartstatcombocharts
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chart-stat-combo.jsonchart-stat-combo.tsx
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type RangeKey = "7d" | "30d" | "90d";
type ViewKey = "chart" | "table";
type Unit = "currency" | "count" | "percent";
type TrendKind = "area" | "bars" | "line";
type IconName = "revenue" | "signups" | "conversion" | "goal";
interface TrendMetric {
type: "trend";
id: string;
label: string;
short: string;
unit: Unit;
kind: TrendKind;
icon: IconName;
caption: string;
series: Record<RangeKey, number[]>;
}
interface RingMetric {
type: "ring";
id: string;
label: string;
short: string;
icon: IconName;
value: number;
goal: number;
deltaPct: number;
caption: string;
}
type Metric = TrendMetric | RingMetric;
interface Pt {
x: number;
y: number;
}
/* ------------------------------------------------------------------ */
/* Data — one shared time axis, so every card is scoped by one filter */
/* ------------------------------------------------------------------ */
const RANGE_LABELS: Record<RangeKey, string[]> = {
"7d": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"30d": [
"Jun 20",
"Jun 23",
"Jun 26",
"Jun 29",
"Jul 2",
"Jul 5",
"Jul 8",
"Jul 11",
"Jul 14",
"Jul 17",
],
"90d": [
"Apr 24",
"May 1",
"May 8",
"May 15",
"May 22",
"May 29",
"Jun 5",
"Jun 12",
"Jun 19",
"Jun 26",
"Jul 3",
"Jul 10",
],
};
const RANGE_TEXT: Record<RangeKey, string> = {
"7d": "the last 7 days",
"30d": "the last 30 days",
"90d": "the last 90 days",
};
const METRICS: Metric[] = [
{
type: "trend",
id: "revenue",
label: "Net revenue",
short: "Revenue",
unit: "currency",
kind: "area",
icon: "revenue",
caption: "Billed, minus refunds and credits.",
series: {
"7d": [38200, 41000, 39800, 43600, 45200, 44100, 48200],
"30d": [31000, 33500, 32800, 35900, 37200, 40100, 39400, 42800, 45600, 48200],
"90d": [
22000, 24500, 23800, 27100, 29900, 31200, 34000, 36800, 39900, 42500, 45300,
48200,
],
},
},
{
type: "trend",
id: "signups",
label: "New signups",
short: "Signups",
unit: "count",
kind: "bars",
icon: "signups",
caption: "Verified accounts, bots excluded.",
series: {
"7d": [120, 142, 131, 158, 149, 171, 183],
"30d": [96, 110, 118, 131, 127, 142, 150, 163, 171, 183],
"90d": [64, 78, 85, 92, 101, 110, 119, 131, 144, 158, 170, 183],
},
},
{
type: "trend",
id: "conversion",
label: "Trial conversion",
short: "Conv. rate",
unit: "percent",
kind: "line",
icon: "conversion",
caption: "Trials that became paid plans.",
series: {
"7d": [3.1, 3.3, 3.2, 3.5, 3.6, 3.5, 3.8],
"30d": [2.8, 2.9, 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.7, 3.8],
"90d": [2.3, 2.4, 2.5, 2.6, 2.8, 2.9, 3.0, 3.2, 3.4, 3.5, 3.7, 3.8],
},
},
{
type: "ring",
id: "goal",
label: "Quarter target",
short: "Q3 target",
icon: "goal",
value: 312000,
goal: 400000,
deltaPct: 14.2,
caption: "Quarter to date — not scoped by the range filter.",
},
];
/* ------------------------------------------------------------------ */
/* Geometry + formatting */
/* ------------------------------------------------------------------ */
const H = 96;
const PAD_X = 10;
const PAD_T = 16;
const PAD_B = 18;
const BASELINE = H - PAD_B;
function compact(n: number): string {
const abs = Math.abs(n);
if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
if (abs >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
return String(Math.round(n));
}
function formatValue(v: number, unit: Unit): string {
if (unit === "currency") return `$${compact(v)}`;
if (unit === "percent") return `${v.toFixed(1)}%`;
return Math.round(v).toLocaleString("en-US");
}
function signed(delta: number): string {
return `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`;
}
/** Smooth cubic path through points (midpoint control handles). */
function buildSmoothPath(pts: Pt[]): string {
if (pts.length === 0) return "";
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i];
const p1 = pts[i + 1];
const cx = (p0.x + p1.x) / 2;
d += ` C ${cx.toFixed(2)} ${p0.y.toFixed(2)}, ${cx.toFixed(2)} ${p1.y.toFixed(
2,
)}, ${p1.x.toFixed(2)} ${p1.y.toFixed(2)}`;
}
return d;
}
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/* ------------------------------------------------------------------ */
/* Icons — inline, no icon library */
/* ------------------------------------------------------------------ */
function MetricIcon({ name }: { name: IconName }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
className="h-4 w-4"
>
{name === "revenue" && (
<>
<polyline points="3 16 9 10 13 14 21 6" />
<polyline points="15 6 21 6 21 12" />
</>
)}
{name === "signups" && (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M19 8v6M22 11h-6" />
</>
)}
{name === "conversion" && (
<>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="1.5" />
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
</>
)}
{name === "goal" && (
<>
<path d="M4 21V4" />
<path d="M4 4h11l-1.6 4L15 12H4" />
</>
)}
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Delta chip — status colour NEVER carries meaning alone. */
/* emerald/rose collide under deuteranopia, so the arrow + signed */
/* number + screen-reader word are the real encoding. */
/* ------------------------------------------------------------------ */
function DeltaChip({ delta }: { delta: number }) {
const up = delta >= 0;
return (
<span
className={[
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
up
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
: "bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
].join(" ")}
>
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
className="h-3 w-3"
>
{up ? (
<path d="M6 10V2M2.6 5.4 6 2l3.4 3.4" />
) : (
<path d="M6 2v8M2.6 6.6 6 10l3.4-3.4" />
)}
</svg>
{signed(delta)}
<span className="sr-only">{up ? "increase" : "decrease"}</span>
</span>
);
}
/* ------------------------------------------------------------------ */
/* Segmented control — roving tabindex radiogroup */
/* ------------------------------------------------------------------ */
interface Option<T extends string> {
k: T;
label: string;
hint: string;
}
function Segmented<T extends string>({
legend,
value,
options,
onChange,
}: {
legend: string;
value: T;
options: Option<T>[];
onChange: (v: T) => void;
}) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const index = options.findIndex((o) => o.k === value);
const focusTo = (next: number) => {
const i = (next + options.length) % options.length;
onChange(options[i].k);
refs.current[i]?.focus();
};
return (
<div
role="radiogroup"
aria-label={legend}
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-900"
>
{options.map((o, i) => {
const active = o.k === value;
return (
<button
key={o.k}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
aria-label={o.hint}
tabIndex={active ? 0 : -1}
onClick={() => onChange(o.k)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
focusTo(index + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
focusTo(index - 1);
} else if (e.key === "Home") {
e.preventDefault();
focusTo(0);
} else if (e.key === "End") {
e.preventDefault();
focusTo(options.length - 1);
}
}}
className={[
"rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2",
"focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
active
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
].join(" ")}
>
{o.label}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Trend chart — measured in real pixels (no aspect distortion) */
/* ------------------------------------------------------------------ */
function TrendChart({
values,
labels,
unit,
kind,
metricLabel,
rangeText,
reduce,
}: {
values: number[];
labels: string[];
unit: Unit;
kind: TrendKind;
metricLabel: string;
rangeText: string;
reduce: boolean;
}) {
const rawId = useId();
const gid = `csc-grad-${rawId.replace(/:/g, "")}`;
const wrapRef = useRef<HTMLDivElement | null>(null);
const [w, setW] = useState(300);
const [active, setActive] = useState<number | null>(null);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const cr = entries[0]?.contentRect;
if (cr && cr.width > 0) setW(Math.max(160, Math.round(cr.width)));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const geo = useMemo(() => {
const n = values.length;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const innerW = Math.max(1, w - PAD_X * 2);
const xs = values.map((_, i) =>
n === 1 ? w / 2 : PAD_X + (i / (n - 1)) * innerW,
);
const ys = values.map(
(v) => PAD_T + (1 - (v - min) / span) * (BASELINE - PAD_T),
);
const pts: Pt[] = xs.map((x, i) => ({ x, y: ys[i] }));
const line = buildSmoothPath(pts);
const area =
n > 1
? `${line} L ${xs[n - 1].toFixed(2)} ${H} L ${xs[0].toFixed(2)} ${H} Z`
: "";
const slot = innerW / n;
const bw = Math.max(3, Math.min(14, slot - 4)); // >=2px surface gap, capped thin
const bars = values.map((_, i) => ({
x: xs[i] - bw / 2,
y: ys[i],
h: Math.max(3, BASELINE - ys[i]),
}));
return { n, xs, ys, line, area, bars, bw };
}, [values, w]);
const n = geo.n;
const shown = active === null ? null : Math.min(active, n - 1);
const step = (dir: number) =>
setActive((i) => {
const from = i === null ? n - 1 : Math.min(i, n - 1);
return Math.min(n - 1, Math.max(0, from + dir));
});
const first = values[0];
const last = values[n - 1];
const delta = first === 0 ? 0 : ((last - first) / first) * 100;
const kindWord = kind === "bars" ? "bar" : kind;
return (
<div className="mt-4">
<div
ref={wrapRef}
tabIndex={0}
role="group"
aria-label={`${metricLabel} ${kindWord} chart. Latest ${formatValue(
last,
unit,
)}, ${signed(delta)} across ${rangeText}. Use the left and right arrow keys to read each point.`}
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width === 0) return;
const px = ((e.clientX - rect.left) / rect.width) * w;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < geo.xs.length; i++) {
const d = Math.abs(geo.xs[i] - px);
if (d < bestD) {
bestD = d;
best = i;
}
}
setActive(best);
}}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive((i) => (i === null ? n - 1 : i))}
onBlur={() => setActive(null)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
step(1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
step(-1);
} else if (e.key === "Home") {
e.preventDefault();
setActive(0);
} else if (e.key === "End") {
e.preventDefault();
setActive(n - 1);
} else if (e.key === "Escape") {
setActive(null);
}
}}
className="relative cursor-crosshair rounded-lg text-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
style={{ height: H }}
>
<svg
width={w}
height={H}
viewBox={`0 0 ${w} ${H}`}
className="block overflow-visible"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.28} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0.02} />
</linearGradient>
</defs>
{/* recessive solid hairline baseline — never dashed */}
<line
x1={0}
y1={BASELINE + 0.5}
x2={w}
y2={BASELINE + 0.5}
className="stroke-slate-200 dark:stroke-slate-800"
strokeWidth={1}
/>
{kind === "area" && (
<>
<motion.path
d={geo.area}
fill={`url(#${gid})`}
{...(reduce
? {}
: {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.6, ease: "easeOut" },
})}
/>
<motion.path
d={geo.line}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...(reduce
? {}
: {
initial: { pathLength: 0 },
animate: { pathLength: 1 },
transition: { duration: 1, ease: "easeInOut" },
})}
/>
</>
)}
{kind === "line" && (
<motion.path
d={geo.line}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...(reduce
? {}
: {
initial: { pathLength: 0 },
animate: { pathLength: 1 },
transition: { duration: 1, ease: "easeInOut" },
})}
/>
)}
{kind === "bars" &&
geo.bars.map((b, i) => (
<motion.rect
key={i}
x={b.x}
y={b.y}
width={geo.bw}
height={b.h}
rx={4}
className="fill-current"
fillOpacity={shown === null || shown === i ? 1 : 0.45}
{...(reduce
? {}
: {
initial: { height: 0, y: BASELINE },
animate: { height: b.h, y: b.y },
transition: {
duration: 0.5,
delay: i * 0.03,
ease: "easeOut",
},
})}
/>
))}
</svg>
{/* Overlays live in HTML so markers stay circular and crisp */}
{shown !== null && (
<div className="pointer-events-none absolute inset-0">
{kind !== "bars" && (
<span
className="absolute w-px bg-slate-300 dark:bg-slate-700"
style={{
left: geo.xs[shown],
top: PAD_T,
height: BASELINE - PAD_T,
}}
/>
)}
<span
className="csc-halo absolute h-3 w-3 rounded-full bg-indigo-500"
style={{
left: geo.xs[shown],
top: geo.ys[shown],
transform: "translate(-50%, -50%)",
}}
/>
{/* 10px marker with a 2px surface ring */}
<span
className="absolute h-2.5 w-2.5 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-slate-900"
style={{
left: geo.xs[shown],
top: geo.ys[shown],
transform: "translate(-50%, -50%)",
}}
/>
<span
className="csc-tip absolute z-10 whitespace-nowrap rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs shadow-sm dark:border-slate-700 dark:bg-slate-800"
style={{
left: Math.min(Math.max(geo.xs[shown], 46), Math.max(46, w - 46)),
top: geo.ys[shown] - 12,
transform: "translate(-50%, -100%)",
}}
>
<span className="text-slate-500 dark:text-slate-400">
{labels[shown]}
</span>{" "}
<span className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{formatValue(values[shown], unit)}
</span>
</span>
</div>
)}
</div>
{/* x-axis band lives inside the card box, so nothing gets clipped */}
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400">
<span>{labels[0]}</span>
<span>{labels[n - 1]}</span>
</div>
<span role="status" aria-live="polite" className="sr-only">
{shown === null
? ""
: `${labels[shown]}: ${formatValue(values[shown], unit)}`}
</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Goal ring */
/* ------------------------------------------------------------------ */
function GoalRing({
value,
goal,
reduce,
}: {
value: number;
goal: number;
reduce: boolean;
}) {
const pct = Math.round((value / goal) * 100);
const r = 30;
const circ = 2 * Math.PI * r;
const offset = circ * (1 - Math.min(100, Math.max(0, pct)) / 100);
return (
<div className="mt-4 flex items-center gap-4">
<div className="relative h-[76px] w-[76px] shrink-0">
<svg
viewBox="0 0 76 76"
className="h-full w-full -rotate-90 text-indigo-500"
aria-hidden="true"
focusable="false"
>
<circle
cx={38}
cy={38}
r={r}
fill="none"
strokeWidth={7}
className="stroke-slate-200 dark:stroke-slate-800"
/>
<motion.circle
cx={38}
cy={38}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={7}
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={offset}
{...(reduce
? {}
: {
initial: { strokeDashoffset: circ },
animate: { strokeDashoffset: offset },
transition: { duration: 1.1, ease: EASE_OUT },
})}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-semibold text-slate-900 dark:text-slate-100">
{pct}%
</span>
</div>
<p className="text-xs leading-relaxed text-slate-500 dark:text-slate-400">
<span className="font-medium text-slate-700 dark:text-slate-300">
{pct}% of the {formatValue(goal, "currency")} target
</span>
<br />
{formatValue(goal - value, "currency")} still to close by Sep 30.
</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Card */
/* ------------------------------------------------------------------ */
function StatCard({
metric,
range,
reduce,
index,
}: {
metric: Metric;
range: RangeKey;
reduce: boolean;
index: number;
}) {
const rise = reduce
? {}
: {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5, delay: index * 0.07, ease: EASE_OUT },
};
let valueText: string;
let delta: number;
if (metric.type === "trend") {
const values = metric.series[range];
const first = values[0];
const last = values[values.length - 1];
valueText = formatValue(last, metric.unit);
delta = first === 0 ? 0 : ((last - first) / first) * 100;
} else {
valueText = formatValue(metric.value, "currency");
delta = metric.deltaPct;
}
return (
<motion.article
{...rise}
className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<MetricIcon name={metric.icon} />
</span>
<h3 className="text-sm font-medium text-slate-600 dark:text-slate-400">
{metric.label}
</h3>
</div>
<div className="mt-3 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
{/* proportional figures on the hero number — never tabular-nums */}
<span className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
{valueText}
</span>
<DeltaChip delta={delta} />
</div>
{metric.type === "trend" ? (
<TrendChart
values={metric.series[range]}
labels={RANGE_LABELS[range]}
unit={metric.unit}
kind={metric.kind}
metricLabel={metric.label}
rangeText={RANGE_TEXT[range]}
reduce={reduce}
/>
) : (
<GoalRing value={metric.value} goal={metric.goal} reduce={reduce} />
)}
<p className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
{metric.caption}
</p>
</motion.article>
);
}
/* ------------------------------------------------------------------ */
/* Table view — the WCAG-clean twin of every chart above */
/* ------------------------------------------------------------------ */
function DataTable({ range }: { range: RangeKey }) {
const labels = RANGE_LABELS[range];
const trends = METRICS.filter((m): m is TrendMetric => m.type === "trend");
return (
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<table className="w-full min-w-[520px] border-collapse text-sm">
<caption className="px-5 pt-5 text-left text-xs text-slate-500 dark:text-slate-400">
Every plotted value for {RANGE_TEXT[range]}, in full.
</caption>
<thead>
<tr className="border-b border-slate-200 dark:border-slate-800">
<th
scope="col"
className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Period
</th>
{trends.map((m) => (
<th
key={m.id}
scope="col"
className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
{m.short}
</th>
))}
</tr>
</thead>
<tbody>
{labels.map((label, i) => (
<tr
key={label}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/70"
>
<th
scope="row"
className="px-5 py-2.5 text-left font-medium text-slate-700 dark:text-slate-300"
>
{label}
</th>
{trends.map((m) => (
<td
key={m.id}
className="px-5 py-2.5 text-right tabular-nums text-slate-600 dark:text-slate-400"
>
{formatValue(m.series[range][i], m.unit)}
</td>
))}
</tr>
))}
</tbody>
</table>
<p className="px-5 pb-5 pt-3 text-xs text-slate-500 dark:text-slate-400">
Quarter target: $312K of $400K (78%), up 14.2% on Q2. Quarter to date, so it
ignores the range filter.
</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
const RANGE_OPTIONS: Option<RangeKey>[] = [
{ k: "7d", label: "7D", hint: "Last 7 days" },
{ k: "30d", label: "30D", hint: "Last 30 days" },
{ k: "90d", label: "90D", hint: "Last 90 days" },
];
const VIEW_OPTIONS: Option<ViewKey>[] = [
{ k: "chart", label: "Charts", hint: "Chart view" },
{ k: "table", label: "Table", hint: "Table view" },
];
const CSS = `
@keyframes csc-halo {
0%, 100% { opacity: .45; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0; transform: translate(-50%, -50%) scale(2.4); }
}
@keyframes csc-tip {
from { opacity: 0; transform: translate(-50%, -100%) translateY(3px); }
to { opacity: 1; transform: translate(-50%, -100%) translateY(0); }
}
.csc-halo { animation: csc-halo 1.8s ease-in-out infinite; }
.csc-tip { animation: csc-tip .14s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.csc-halo { animation: none; opacity: .35; }
.csc-tip { animation: none; }
}
`;
export default function ChartStatCombo() {
const reduce = !!useReducedMotion();
const [range, setRange] = useState<RangeKey>("30d");
const [view, setView] = useState<ViewKey>("chart");
return (
<section className="relative w-full bg-slate-50 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{CSS}</style>
<div className="mx-auto max-w-6xl px-6">
<header className="flex flex-wrap items-end justify-between gap-6">
<div className="max-w-lg">
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
Growth
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
Performance overview
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Scrub any chart with your pointer, or focus it and use the{" "}
<kbd className="rounded border border-slate-300 px-1 font-sans text-[11px] dark:border-slate-700">
←
</kbd>{" "}
<kbd className="rounded border border-slate-300 px-1 font-sans text-[11px] dark:border-slate-700">
→
</kbd>{" "}
keys. Every value is also in the table view.
</p>
</div>
{/* One filter row scoping everything below — never per-card filters */}
<div className="flex flex-wrap items-center gap-2">
<Segmented
legend="Time range"
value={range}
options={RANGE_OPTIONS}
onChange={setRange}
/>
<Segmented
legend="Display as"
value={view}
options={VIEW_OPTIONS}
onChange={setView}
/>
</div>
</header>
<div className="mt-10">
{view === "chart" ? (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-4">
{METRICS.map((m, i) => (
<StatCard
key={m.id}
metric={m}
range={range}
reduce={reduce}
index={i}
/>
))}
</div>
) : (
<DataTable range={range} />
)}
</div>
<p className="mt-6 text-xs text-slate-500 dark:text-slate-400">
Figures are illustrative sample data. Colour never carries meaning on its
own: each card is named and iconned, and every rise or fall ships with an
arrow and a signed number.
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

