Dashboard Chart Widget
Original · freechart panel widget with tabs
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/dash-chart-widget.json"use client";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type SeriesKey = "revenue" | "signups" | "churn";
type RangeKey = 7 | 14 | 30;
interface Pt {
x: number;
y: number;
}
interface SeriesDef {
key: SeriesKey;
label: string;
caption: string;
agg: "sum" | "avg";
goodWhen: "up" | "down";
accent: string;
swatch: string;
data: readonly number[];
format: (v: number) => string;
formatAxis: (v: number) => string;
}
const DAY_MS = 86_400_000;
const END_UTC = Date.UTC(2026, 6, 17);
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const LABELS: readonly string[] = Array.from({ length: 60 }, (_, i) => {
const d = new Date(END_UTC - (59 - i) * DAY_MS);
return `${MONTHS[d.getUTCMonth()] ?? ""} ${d.getUTCDate()}`;
});
const REVENUE: readonly number[] = [
2410, 2580, 2496, 2634, 1810, 1682, 2520, 2688, 2742, 2610, 2830, 1904, 1755, 2612, 2764, 2910,
2848, 3012, 2044, 1868, 2735, 2896, 3080, 2965, 3140, 2118, 1946, 2810, 3024, 3196, 3105, 3288,
2210, 2032, 2944, 3162, 3340, 3255, 3402, 2286, 2115, 3068, 3288, 3475, 3390, 3560, 2380, 2204,
3190, 3420, 3610, 3548, 3702, 2465, 2288, 3315, 3560, 3744, 3672, 3905,
];
const SIGNUPS: readonly number[] = [
48, 52, 50, 55, 31, 28, 47, 54, 58, 55, 61, 34, 30, 52, 57, 63, 60, 66, 37, 33, 56, 62, 68, 65,
71, 40, 36, 60, 66, 73, 70, 77, 43, 38, 64, 71, 78, 75, 82, 46, 41, 69, 75, 84, 80, 88, 49, 44,
73, 80, 89, 86, 94, 53, 47, 78, 85, 95, 91, 101,
];
const CHURN: readonly number[] = [
4.8, 4.7, 4.9, 4.6, 4.5, 4.7, 4.6, 4.5, 4.4, 4.6, 4.3, 4.4, 4.2, 4.3, 4.2, 4.1, 4.3, 4.0, 4.1,
3.9, 4.0, 3.9, 4.0, 3.8, 3.7, 3.9, 3.6, 3.8, 3.7, 3.5, 3.6, 3.4, 3.6, 3.3, 3.5, 3.4, 3.2, 3.3,
3.1, 3.3, 3.0, 3.2, 3.1, 3.0, 3.2, 2.9, 3.1, 2.8, 3.0, 2.9, 3.1, 2.8, 3.0, 2.7, 2.9, 2.8, 2.7,
2.9, 2.6, 2.8,
];
function group(n: number): string {
return Math.round(n)
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
const SERIES: readonly SeriesDef[] = [
{
key: "revenue",
label: "Revenue",
caption: "Net MRR booked per day, refunds deducted.",
agg: "sum",
goodWhen: "up",
accent: "text-indigo-600 dark:text-indigo-400",
swatch: "bg-indigo-600 dark:bg-indigo-400",
data: REVENUE,
format: (v) => `$${group(v)}`,
formatAxis: (v) => (v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${group(v)}`),
},
{
key: "signups",
label: "Signups",
caption: "Verified accounts created, bots filtered out.",
agg: "sum",
goodWhen: "up",
accent: "text-emerald-600 dark:text-emerald-400",
swatch: "bg-emerald-600 dark:bg-emerald-400",
data: SIGNUPS,
format: (v) => group(v),
formatAxis: (v) => group(v),
},
{
key: "churn",
label: "Churn rate",
caption: "Rolling 30-day logo churn, billed workspaces only.",
agg: "avg",
goodWhen: "down",
accent: "text-rose-600 dark:text-rose-400",
swatch: "bg-rose-600 dark:bg-rose-400",
data: CHURN,
format: (v) => `${v.toFixed(1)}%`,
formatAxis: (v) => `${v.toFixed(1)}%`,
},
];
const RANGES: readonly { key: RangeKey; label: string; long: string }[] = [
{ key: 7, label: "7D", long: "Last 7 days" },
{ key: 14, label: "14D", long: "Last 14 days" },
{ key: 30, label: "30D", long: "Last 30 days" },
];
const W = 720;
const H = 260;
const PAD_L = 52;
const PAD_R = 16;
const PAD_T = 20;
const PAD_B = 34;
const PLOT_W = W - PAD_L - PAD_R;
const PLOT_H = H - PAD_T - PAD_B;
function toCurve(pts: readonly Pt[]): string {
const n = pts.length;
if (n === 0) return "";
const get = (i: number): Pt => pts[Math.min(n - 1, Math.max(0, i))] ?? { x: 0, y: 0 };
let d = `M ${get(0).x.toFixed(2)} ${get(0).y.toFixed(2)}`;
for (let i = 0; i < n - 1; i += 1) {
const p0 = get(i - 1);
const p1 = get(i);
const p2 = get(i + 1);
const p3 = get(i + 2);
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
return d;
}
function toPolyline(pts: readonly Pt[]): string {
return pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(" ");
}
function aggregate(values: readonly number[], agg: "sum" | "avg"): number {
if (values.length === 0) return 0;
const total = values.reduce((acc, v) => acc + v, 0);
return agg === "sum" ? total : total / values.length;
}
export default function DashChartWidget() {
const reduce = useReducedMotion();
const rawId = useId();
const uid = rawId.replace(/:/g, "");
const [seriesKey, setSeriesKey] = useState<SeriesKey>("revenue");
const [range, setRange] = useState<RangeKey>(30);
const [compare, setCompare] = useState<boolean>(true);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [chartFocused, setChartFocused] = useState<boolean>(false);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const rangeRefs = useRef<Array<HTMLButtonElement | null>>([]);
const seriesIndex = SERIES.findIndex((s) => s.key === seriesKey);
const series = SERIES[seriesIndex] ?? SERIES[0];
const rangeIndex = RANGES.findIndex((r) => r.key === range);
const model = useMemo(() => {
const def = SERIES.find((s) => s.key === seriesKey) ?? SERIES[0];
const data = def?.data ?? [];
const n = range;
const start = data.length - n;
const current = data.slice(start);
const previous = data.slice(start - n, start);
const pool = compare ? [...current, ...previous] : [...current];
const rawMin = Math.min(...pool);
const rawMax = Math.max(...pool);
const spread = rawMax - rawMin || Math.abs(rawMax) || 1;
const minV = rawMin - spread * 0.14;
const maxV = rawMax + spread * 0.14;
const xOf = (i: number): number => PAD_L + (n === 1 ? PLOT_W / 2 : (i / (n - 1)) * PLOT_W);
const yOf = (v: number): number => PAD_T + (1 - (v - minV) / (maxV - minV)) * PLOT_H;
const curPts: Pt[] = current.map((v, i) => ({ x: xOf(i), y: yOf(v) }));
const prevPts: Pt[] = previous.map((v, i) => ({ x: xOf(i), y: yOf(v) }));
const baseY = PAD_T + PLOT_H;
const firstX = curPts[0]?.x ?? PAD_L;
const lastX = curPts[curPts.length - 1]?.x ?? PAD_L + PLOT_W;
const linePath = toCurve(curPts);
const areaPath = `${linePath} L ${lastX.toFixed(2)} ${baseY} L ${firstX.toFixed(2)} ${baseY} Z`;
const ticks = Array.from({ length: 5 }, (_, t) => {
const v = maxV - ((maxV - minV) * t) / 4;
return { y: yOf(v), label: def?.formatAxis(v) ?? "" };
});
const labelStep = Math.max(1, Math.floor((n - 1) / 4));
const xTicks = current
.map((_, i) => i)
.filter((i) => i % labelStep === 0 || i === n - 1)
.map((i) => ({ i, x: xOf(i), label: LABELS[start + i] ?? "" }));
const curAgg = aggregate(current, def?.agg ?? "sum");
const prevAgg = aggregate(previous, def?.agg ?? "sum");
const deltaPct = prevAgg === 0 ? 0 : ((curAgg - prevAgg) / prevAgg) * 100;
let peakI = 0;
let lowI = 0;
current.forEach((v, i) => {
if (v > (current[peakI] ?? 0)) peakI = i;
if (v < (current[lowI] ?? 0)) lowI = i;
});
return {
n,
start,
current,
previous,
curPts,
prevPts,
linePath,
areaPath,
prevPath: toPolyline(prevPts),
ticks,
xTicks,
curAgg,
prevAgg,
deltaPct,
peakI,
lowI,
baseY,
average: aggregate(current, "avg"),
};
}, [seriesKey, range, compare]);
const displayIndex = activeIndex ?? model.n - 1;
const activePt = model.curPts[displayIndex];
const activeValue = model.current[displayIndex] ?? 0;
const comparePt = model.prevPts[displayIndex];
const compareValue = model.previous[displayIndex] ?? 0;
const activeLabel = LABELS[model.start + displayIndex] ?? "";
const prevLabel = LABELS[model.start - model.n + displayIndex] ?? "";
const showTip = activeIndex !== null;
const positive = model.deltaPct >= 0;
const good = series
? (series.goodWhen === "up" && positive) || (series.goodWhen === "down" && !positive)
: positive;
const onTabKeyDown = useCallback((e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key)) return;
e.preventDefault();
let next = index;
if (e.key === "ArrowRight") next = (index + 1) % SERIES.length;
if (e.key === "ArrowLeft") next = (index - 1 + SERIES.length) % SERIES.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = SERIES.length - 1;
const def = SERIES[next];
if (!def) return;
setSeriesKey(def.key);
setActiveIndex(null);
tabRefs.current[next]?.focus();
}, []);
const onRangeKeyDown = useCallback((e: ReactKeyboardEvent<HTMLButtonElement>, index: number) => {
if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(e.key)) return;
e.preventDefault();
let next = index;
if (e.key === "ArrowRight") next = (index + 1) % RANGES.length;
if (e.key === "ArrowLeft") next = (index - 1 + RANGES.length) % RANGES.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = RANGES.length - 1;
const def = RANGES[next];
if (!def) return;
setRange(def.key);
setActiveIndex(null);
rangeRefs.current[next]?.focus();
}, []);
const onChartKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
const last = model.n - 1;
const cur = activeIndex ?? last;
if (e.key === "ArrowRight") {
e.preventDefault();
setActiveIndex(Math.min(last, cur + 1));
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setActiveIndex(Math.max(0, cur - 1));
} else if (e.key === "Home") {
e.preventDefault();
setActiveIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setActiveIndex(last);
} else if (e.key === "Escape") {
setActiveIndex(null);
}
},
[activeIndex, model.n],
);
const tipLeft = Math.min(88, Math.max(12, ((activePt?.x ?? PAD_L) / W) * 100));
const tipTop = ((activePt?.y ?? PAD_T) / H) * 100;
const rangeLong = RANGES[rangeIndex]?.long ?? "Last 30 days";
const panelId = `${uid}-panel`;
return (
<section className="relative w-full bg-zinc-100 px-4 py-16 sm:px-6 sm:py-20 dark:bg-zinc-950">
<style>{`
@keyframes dcw-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes dcw-pulse-ring {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(2.4); opacity: 0; }
100% { transform: scale(2.4); opacity: 0; }
}
.dcw-rise { animation: dcw-rise 620ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.dcw-ring { animation: dcw-pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@media (prefers-reduced-motion: reduce) {
.dcw-rise, .dcw-ring { animation: none !important; }
}
`}</style>
<div className="dcw-rise mx-auto w-full max-w-5xl">
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/30">
{/* Header */}
<div className="flex flex-col gap-4 border-b border-zinc-200 p-5 sm:p-6 dark:border-zinc-800">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
Growth overview
</h2>
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 ring-1 ring-emerald-600/20 ring-inset dark:bg-emerald-950/60 dark:text-emerald-300 dark:ring-emerald-400/20">
<span className="relative flex h-1.5 w-1.5">
<span className="dcw-ring absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
</div>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Northwind Studio workspace · synced 4 min ago · UTC
</p>
</div>
{/* Range radiogroup */}
<div
role="radiogroup"
aria-label="Time range"
className="inline-flex rounded-lg bg-zinc-100 p-0.5 dark:bg-zinc-800"
>
{RANGES.map((r, i) => {
const selected = r.key === range;
return (
<button
key={r.key}
ref={(el) => {
rangeRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
aria-label={r.long}
tabIndex={selected ? 0 : -1}
onClick={() => {
setRange(r.key);
setActiveIndex(null);
}}
onKeyDown={(e) => onRangeKeyDown(e, i)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
selected
? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
>
{r.label}
</button>
);
})}
</div>
</div>
{/* Tabs */}
<div
role="tablist"
aria-label="Metric"
aria-orientation="horizontal"
className="-mb-5 flex gap-1 sm:-mb-6"
>
{SERIES.map((s, i) => {
const selected = s.key === seriesKey;
return (
<button
key={s.key}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`${uid}-tab-${s.key}`}
aria-selected={selected}
aria-controls={panelId}
tabIndex={selected ? 0 : -1}
onClick={() => {
setSeriesKey(s.key);
setActiveIndex(null);
}}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={`relative rounded-t-md px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-inset ${
selected
? "text-zinc-900 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
>
{s.label}
{selected ? (
<motion.span
layoutId={`${uid}-tab-underline`}
className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-zinc-900 dark:bg-zinc-50"
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 420, damping: 34 }
}
/>
) : null}
</button>
);
})}
</div>
</div>
{/* Panel */}
<div
role="tabpanel"
id={panelId}
aria-labelledby={`${uid}-tab-${series?.key ?? "revenue"}`}
className="p-5 sm:p-6"
>
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="flex items-baseline gap-3">
<span className="text-3xl font-semibold tracking-tight tabular-nums text-zinc-900 sm:text-4xl dark:text-zinc-50">
{series?.format(model.curAgg)}
</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ring-inset ${
good
? "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950/60 dark:text-emerald-300 dark:ring-emerald-400/20"
: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-950/60 dark:text-rose-300 dark:ring-rose-400/20"
}`}
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={`h-3 w-3 ${positive ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 13V3" />
<path d="M3.5 7.5 8 3l4.5 4.5" />
</svg>
{positive ? "+" : "−"}
{Math.abs(model.deltaPct).toFixed(1)}%
</span>
</div>
<p className="mt-1.5 text-sm text-zinc-500 dark:text-zinc-400">
{rangeLong} vs previous {model.n} days · {series?.caption}
</p>
</div>
{/* Compare switch */}
<div className="flex items-center gap-2.5">
<span
id={`${uid}-cmp-label`}
className="text-xs font-medium text-zinc-600 dark:text-zinc-400"
>
Compare previous period
</span>
<button
type="button"
role="switch"
aria-checked={compare}
aria-labelledby={`${uid}-cmp-label`}
onClick={() => setCompare((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full 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-zinc-900 ${
compare ? "bg-indigo-600 dark:bg-indigo-500" : "bg-zinc-300 dark:bg-zinc-700"
}`}
>
<span
className={`pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ease-out ${
compare ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
{/* Chart */}
<div
tabIndex={0}
role="group"
aria-label={`${series?.label ?? "Metric"} chart, ${rangeLong}. Use the left and right arrow keys to inspect each day.`}
onKeyDown={onChartKeyDown}
onFocus={() => {
setChartFocused(true);
setActiveIndex((v) => v ?? model.n - 1);
}}
onBlur={() => {
setChartFocused(false);
setActiveIndex(null);
}}
onPointerLeave={() => {
if (!chartFocused) setActiveIndex(null);
}}
className="relative mt-5 rounded-xl 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-zinc-900"
>
<svg
viewBox={`0 0 ${W} ${H}`}
className={`h-auto w-full ${series?.accent ?? "text-indigo-600"}`}
role="img"
aria-label={`${series?.label ?? "Metric"} from ${LABELS[model.start] ?? ""} to ${LABELS[59] ?? ""}. Values are listed in the data table below.`}
>
<defs>
<linearGradient id={`${uid}-fill`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
{/* Gridlines + y labels */}
{model.ticks.map((t, i) => (
<g key={`y-${i}`}>
<line
x1={PAD_L}
x2={W - PAD_R}
y1={t.y}
y2={t.y}
className="stroke-zinc-200 dark:stroke-zinc-800"
strokeWidth="1"
strokeDasharray={i === model.ticks.length - 1 ? undefined : "3 5"}
/>
<text
x={PAD_L - 10}
y={t.y + 3.5}
textAnchor="end"
className="fill-zinc-400 text-[10px] tabular-nums dark:fill-zinc-500"
>
{t.label}
</text>
</g>
))}
{/* X labels */}
{model.xTicks.map((t) => (
<text
key={`x-${t.i}`}
x={t.x}
y={H - 12}
textAnchor="middle"
className="fill-zinc-400 text-[10px] dark:fill-zinc-500"
>
{t.label}
</text>
))}
{/* Previous period */}
{compare ? (
<motion.path
key={`${seriesKey}-${range}-prev`}
d={model.prevPath}
fill="none"
strokeDasharray="4 4"
strokeWidth="1.75"
strokeLinecap="round"
className="stroke-zinc-400 dark:stroke-zinc-600"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: reduce ? 0 : 0.4 }}
/>
) : null}
{/* Area */}
<motion.path
key={`${seriesKey}-${range}-${compare ? "c" : "n"}-area`}
d={model.areaPath}
fill={`url(#${uid}-fill)`}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: reduce ? 0 : 0.55, delay: reduce ? 0 : 0.18 }}
/>
{/* Line */}
<motion.path
key={`${seriesKey}-${range}-${compare ? "c" : "n"}-line`}
d={model.linePath}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: reduce ? 0 : 0.85, ease: [0.22, 1, 0.36, 1] }}
/>
{/* Point markers on short ranges */}
{model.n <= 14
? model.curPts.map((p, i) => (
<circle
key={`dot-${i}`}
cx={p.x}
cy={p.y}
r="3"
fill="currentColor"
className="opacity-70"
/>
))
: null}
{/* Crosshair + active point */}
{showTip && activePt ? (
<g>
<line
x1={activePt.x}
x2={activePt.x}
y1={PAD_T}
y2={model.baseY}
className="stroke-zinc-300 dark:stroke-zinc-600"
strokeWidth="1"
/>
{compare && comparePt ? (
<circle
cx={comparePt.x}
cy={comparePt.y}
r="3.5"
className="fill-white stroke-zinc-400 dark:fill-zinc-900 dark:stroke-zinc-500"
strokeWidth="2"
/>
) : null}
<circle cx={activePt.x} cy={activePt.y} r="7" fill="currentColor" opacity="0.18" />
<circle
cx={activePt.x}
cy={activePt.y}
r="4"
fill="currentColor"
className="stroke-white dark:stroke-zinc-900"
strokeWidth="2"
/>
</g>
) : null}
{/* Hit areas */}
{model.curPts.map((p, i) => (
<rect
key={`hit-${i}`}
x={p.x - PLOT_W / (model.n - 1) / 2}
y={PAD_T}
width={PLOT_W / (model.n - 1)}
height={PLOT_H}
fill="transparent"
onMouseEnter={() => setActiveIndex(i)}
/>
))}
</svg>
{/* Tooltip */}
{showTip && activePt ? (
<div
className="pointer-events-none absolute z-10 w-max -translate-x-1/2 -translate-y-[calc(100%+14px)] rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-lg shadow-zinc-900/10 dark:border-zinc-700 dark:bg-zinc-950 dark:shadow-black/50"
style={{ left: `${tipLeft}%`, top: `${tipTop}%` }}
>
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
{activeLabel}, 2026
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
<span className={`h-2 w-2 rounded-full ${series?.swatch ?? "bg-indigo-600"}`} />
{series?.format(activeValue)}
</p>
{compare ? (
<p className="mt-0.5 flex items-center gap-1.5 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
<span className="h-2 w-2 rounded-full border border-dashed border-zinc-400 dark:border-zinc-500" />
{series?.format(compareValue)} on {prevLabel}
</p>
) : null}
</div>
) : null}
</div>
{/* Legend */}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 rounded-full ${series?.swatch ?? "bg-indigo-600"}`} />
{rangeLong}
</span>
{compare ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-0 w-4 border-t-2 border-dashed border-zinc-400 dark:border-zinc-500" />
Previous {model.n} days
</span>
) : null}
<span className="ml-auto hidden sm:inline">
Focus the chart and use ← → to step through days
</span>
</div>
{/* Footer stats */}
<dl className="mt-5 grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-zinc-200 bg-zinc-200 sm:grid-cols-3 dark:border-zinc-800 dark:bg-zinc-800">
<div className="bg-white px-4 py-3 dark:bg-zinc-900">
<dt className="text-xs text-zinc-500 dark:text-zinc-400">Best day</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
{series?.format(model.current[model.peakI] ?? 0)}
<span className="ml-1.5 font-normal text-zinc-400 dark:text-zinc-500">
{LABELS[model.start + model.peakI] ?? ""}
</span>
</dd>
</div>
<div className="bg-white px-4 py-3 dark:bg-zinc-900">
<dt className="text-xs text-zinc-500 dark:text-zinc-400">Daily average</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
{series?.format(model.average)}
</dd>
</div>
<div className="bg-white px-4 py-3 dark:bg-zinc-900">
<dt className="text-xs text-zinc-500 dark:text-zinc-400">Quietest day</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-50">
{series?.format(model.current[model.lowI] ?? 0)}
<span className="ml-1.5 font-normal text-zinc-400 dark:text-zinc-500">
{LABELS[model.start + model.lowI] ?? ""}
</span>
</dd>
</div>
</dl>
{/* Screen-reader announcements + data table */}
<p aria-live="polite" className="sr-only">
{showTip
? `${activeLabel}: ${series?.format(activeValue) ?? ""}${
compare ? `, previous period ${series?.format(compareValue) ?? ""}` : ""
}`
: ""}
</p>
<table className="sr-only">
<caption>
{series?.label ?? "Metric"} by day, {rangeLong}
</caption>
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">{series?.label ?? "Value"}</th>
<th scope="col">Previous period</th>
</tr>
</thead>
<tbody>
{model.current.map((v, i) => (
<tr key={`row-${i}`}>
<th scope="row">{LABELS[model.start + i] ?? ""}</th>
<td>{series?.format(v)}</td>
<td>{series?.format(model.previous[i] ?? 0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</section>
);
}Dependencies
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 →
Dashboard KPI Row
Originalrow of KPI stat cards with trends

Dashboard Stat Widgets
Originalassorted dashboard stat widgets

Dashboard Activity
Originalrecent activity feed widget

Dashboard Revenue
Originalrevenue overview card with chart

Dashboard Users
Originalactive users widget

Dashboard Tasks
Originaltasks/checklist widget

Dashboard Calendar Widget
Originalmini calendar dashboard widget

Dashboard Notifications
Originalnotifications panel widget

Dashboard Overview
Originalcompact dashboard overview grid

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

