Dashboard KPI Row
Original · freerow of KPI stat cards with trends
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-kpi-row.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type Unit = "currency" | "count" | "percent" | "ms";
type RangeId = "7d" | "30d" | "90d";
type Period = {
value: number;
delta: number;
note: string;
};
type Kpi = {
id: string;
label: string;
short: string;
unit: Unit;
seed: number;
lowerIsBetter: boolean;
icon: ReactNode;
data: Record<RangeId, Period>;
};
const RANGES: ReadonlyArray<{ id: RangeId; label: string; long: string; points: number }> = [
{ id: "7d", label: "7D", long: "last 7 days", points: 7 },
{ id: "30d", label: "30D", long: "last 30 days", points: 30 },
{ id: "90d", label: "90D", long: "last 90 days", points: 45 },
];
const IconRevenue = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
<path d="M12 3v18" strokeLinecap="round" />
<path d="M16.5 7.5A3.5 3.5 0 0 0 13 5h-2a3 3 0 0 0 0 6h2a3 3 0 0 1 0 6h-2a3.5 3.5 0 0 1-3.5-2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconWorkspace = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
<path d="M17.25 13.5v7.5M21 17.25h-7.5" strokeLinecap="round" />
</svg>
);
const IconConversion = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
<path d="M3 5h18l-6.75 7.6V20l-4.5-2.6v-4.8L3 5Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconLatency = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
<path d="M13 2 4.5 13.5H11L10 22l8.5-11.5H12L13 2Z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const KPIS: ReadonlyArray<Kpi> = [
{
id: "revenue",
label: "Net revenue",
short: "Revenue",
unit: "currency",
seed: 1.4,
lowerIsBetter: false,
icon: IconRevenue,
data: {
"7d": {
value: 48210,
delta: 4.8,
note: "Two annual upgrades landed on Tuesday and carried most of the week's lift. Weekend volume was flat, as usual.",
},
"30d": {
value: 196400,
delta: 12.4,
note: "The Team → Scale upgrade path is doing the work: 61 accounts moved up, and expansion now outpaces new logos for the third month running.",
},
"90d": {
value: 571900,
delta: 26.1,
note: "Growth is compounding off the March pricing change. Churned revenue held under $9k a month across the whole quarter.",
},
},
},
{
id: "workspaces",
label: "New workspaces",
short: "Workspaces",
unit: "count",
seed: 5.9,
lowerIsBetter: false,
icon: IconWorkspace,
data: {
"7d": {
value: 96,
delta: -2.1,
note: "A quiet week. Signups dipped Thursday when the docs search outage pushed the getting-started page to a 502 for 40 minutes.",
},
"30d": {
value: 412,
delta: 5.6,
note: "Invite-a-teammate now accounts for 38% of new workspaces, up from 24% before the onboarding checklist shipped.",
},
"90d": {
value: 1184,
delta: 18.9,
note: "Organic search is the fastest-growing source at +44% over the quarter, mostly from the integration comparison pages.",
},
},
},
{
id: "conversion",
label: "Checkout conversion",
short: "Conversion",
unit: "percent",
seed: 9.2,
lowerIsBetter: false,
icon: IconConversion,
data: {
"7d": {
value: 3.82,
delta: 0.9,
note: "Recovered after the card-decline copy was rewritten. Apple Pay sessions convert at nearly twice the rate of manual card entry.",
},
"30d": {
value: 3.64,
delta: -3.2,
note: "The dip tracks the new tax-ID field on the billing step — 11% of EU sessions abandon there. Worth making it optional and validating later.",
},
"90d": {
value: 3.41,
delta: -1.4,
note: "Broadly stable. The quarter's low came during the April trial-length test, which we rolled back after two weeks.",
},
},
},
{
id: "latency",
label: "p95 response time",
short: "Latency",
unit: "ms",
seed: 13.6,
lowerIsBetter: true,
icon: IconLatency,
data: {
"7d": {
value: 214,
delta: -6.4,
note: "Steady all week. The single spike to 380 ms was Sunday's index rebuild, which runs off-peak by design.",
},
"30d": {
value: 228,
delta: -18.3,
note: "Connection pooling on the read replicas took roughly 50 ms off every list query. This is the cleanest win of the month.",
},
"90d": {
value: 246,
delta: -24.7,
note: "Three straight months of improvement. The remaining tail is almost entirely the export endpoint, which still runs synchronously.",
},
},
},
];
function shape(seed: number, len: number, drift: number): number[] {
const raw: number[] = [];
for (let i = 0; i < len; i += 1) {
const t = len === 1 ? 1 : i / (len - 1);
const wobble =
0.055 * Math.sin(i * 1.9 + seed) +
0.032 * Math.sin(i * 0.61 + seed * 1.3) +
0.018 * Math.sin(i * 3.4 + seed * 0.7);
raw.push(1 + drift * (t - 1) + wobble);
}
const last = raw[raw.length - 1];
return raw.map((v) => v / last);
}
function buildSeries(seed: number, len: number, drift: number, end: number): number[] {
return shape(seed, len, drift).map((v) => v * end);
}
function clampDrift(deltaPct: number): number {
const d = deltaPct / 100;
return Math.max(-0.34, Math.min(0.34, d));
}
function fmt(v: number, unit: Unit): string {
if (unit === "currency") return `$${Math.round(v).toLocaleString("en-US")}`;
if (unit === "count") return Math.round(v).toLocaleString("en-US");
if (unit === "percent") return `${v.toFixed(2)}%`;
return `${Math.round(v)} ms`;
}
function linePath(vals: number[], min: number, max: number): string {
const span = max - min || 1;
const pad = 3;
const h = 32;
return vals
.map((v, i) => {
const x = vals.length === 1 ? 0 : (i / (vals.length - 1)) * 100;
const y = pad + (1 - (v - min) / span) * (h - pad * 2);
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}
function endPoint(vals: number[], min: number, max: number): { left: number; top: number } {
const span = max - min || 1;
const pad = 3;
const h = 32;
const v = vals[vals.length - 1];
return { left: 100, top: ((pad + (1 - (v - min) / span) * (h - pad * 2)) / h) * 100 };
}
function useCountUp(target: number, enabled: boolean): number {
const [display, setDisplay] = useState(target);
const currentRef = useRef(target);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (!enabled) {
currentRef.current = target;
setDisplay(target);
return;
}
const from = currentRef.current;
if (from === target) return;
const start = performance.now();
const duration = 620;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
const next = from + (target - from) * eased;
currentRef.current = next;
setDisplay(next);
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, [target, enabled]);
return display;
}
function Arrow({ up }: { up: boolean }) {
return (
<svg viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth={1.9} aria-hidden="true">
<path d={up ? "M6 9.5V2.5M6 2.5 2.75 5.75M6 2.5l3.25 3.25" : "M6 2.5v7M6 9.5 2.75 6.25M6 9.5l3.25-3.25"} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export default function DashKpiRow() {
const reduce = useReducedMotion();
const uid = useId().replace(/:/g, "");
const [range, setRange] = useState<RangeId>("30d");
const [compare, setCompare] = useState(true);
const [active, setActive] = useState<string>(KPIS[0].id);
const rangeRefs = useRef<Array<HTMLButtonElement | null>>([]);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const rangeMeta = RANGES.find((r) => r.id === range) ?? RANGES[1];
const points = rangeMeta.points;
const chart = useCallback(
(kpi: Kpi) => {
const period = kpi.data[range];
const drift = clampDrift(period.delta);
const current = buildSeries(kpi.seed, points, drift, period.value);
const prevEnd = period.value / (1 + period.delta / 100);
const previous = buildSeries(kpi.seed + 3.7, points, drift * 0.55, prevEnd);
const pool = compare ? current.concat(previous) : current;
const min = Math.min(...pool);
const max = Math.max(...pool);
return { current, previous, min, max, period };
},
[range, points, compare],
);
const activeKpi = KPIS.find((k) => k.id === active) ?? KPIS[0];
const activeChart = useMemo(() => chart(activeKpi), [chart, activeKpi]);
const summary = useMemo(() => {
const s = activeChart.current;
const sum = s.reduce((a, b) => a + b, 0);
return { peak: Math.max(...s), low: Math.min(...s), avg: sum / s.length };
}, [activeChart]);
function onRangeKey(e: KeyboardEvent<HTMLDivElement>) {
const i = RANGES.findIndex((r) => r.id === range);
let next = i;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % RANGES.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + RANGES.length) % RANGES.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = RANGES.length - 1;
else return;
e.preventDefault();
setRange(RANGES[next].id);
rangeRefs.current[next]?.focus();
}
function onTabKey(e: KeyboardEvent<HTMLDivElement>) {
const i = KPIS.findIndex((k) => k.id === active);
let next = i;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % KPIS.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (i - 1 + KPIS.length) % KPIS.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = KPIS.length - 1;
else return;
e.preventDefault();
setActive(KPIS[next].id);
tabRefs.current[next]?.focus();
}
const ring =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes dkr-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes dkr-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes dkr-halo { 0% { transform: scale(1); opacity: .55; } 70%, 100% { transform: scale(2.6); opacity: 0; } }
.dkr-line { stroke-dasharray: 1; animation: dkr-draw 900ms cubic-bezier(.22,1,.36,1) both; }
.dkr-ghost { animation: dkr-fade 600ms ease-out 140ms both; }
.dkr-area { animation: dkr-fade 700ms ease-out 220ms both; }
.dkr-halo { animation: dkr-halo 2.6s cubic-bezier(.22,1,.36,1) infinite; }
@media (prefers-reduced-motion: reduce) {
.dkr-line, .dkr-ghost, .dkr-area, .dkr-halo { animation: none !important; }
.dkr-line { stroke-dashoffset: 0 !important; }
.dkr-ghost, .dkr-area { opacity: 1 !important; }
.dkr-halo { opacity: 0 !important; }
}
`}</style>
<div className="mx-auto w-full max-w-6xl">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-indigo-600 dark:text-indigo-400">
Overview
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Product health
</h2>
<p className="mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
Compared against the preceding period of equal length. Select a metric to read the breakdown.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div
role="radiogroup"
aria-label="Reporting range"
onKeyDown={onRangeKey}
className="inline-flex rounded-xl border border-slate-200 bg-white p-1 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
{RANGES.map((r, i) => {
const on = r.id === range;
return (
<button
key={r.id}
ref={(el) => {
rangeRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setRange(r.id)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-colors ${ring} ${
on
? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
{r.label}
<span className="sr-only"> — {r.long}</span>
</button>
);
})}
</div>
<button
type="button"
role="switch"
aria-checked={compare}
onClick={() => setCompare((v) => !v)}
className={`inline-flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white py-2 pl-3 pr-3.5 text-xs font-semibold text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 ${ring}`}
>
<span
aria-hidden="true"
className={`relative h-4 w-7 shrink-0 rounded-full transition-colors ${
compare ? "bg-indigo-600 dark:bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-[left] duration-200 ${
compare ? "left-3.5" : "left-0.5"
}`}
/>
</span>
Prior period
</button>
</div>
</div>
<div
role="tablist"
aria-label="Key metrics"
aria-orientation="horizontal"
onKeyDown={onTabKey}
className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{KPIS.map((kpi, i) => (
<Card
key={kpi.id}
kpi={kpi}
index={i}
uid={uid}
range={range}
rangeLong={rangeMeta.long}
compare={compare}
selected={kpi.id === active}
reduce={Boolean(reduce)}
chart={chart(kpi)}
ring={ring}
onSelect={() => setActive(kpi.id)}
tabRef={(el) => {
tabRefs.current[i] = el;
}}
/>
))}
</div>
<div
role="tabpanel"
id={`${uid}-panel-${activeKpi.id}`}
aria-labelledby={`${uid}-tab-${activeKpi.id}`}
tabIndex={0}
className={`mt-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900 ${ring}`}
>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
<div className="lg:w-[46%]">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
{activeKpi.label} · {rangeMeta.long}
</h3>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{activeChart.period.note}
</p>
<dl className="mt-5 grid grid-cols-3 gap-3">
{[
{ k: "Peak", v: summary.peak },
{ k: "Low", v: summary.low },
{ k: "Average", v: summary.avg },
].map((s) => (
<div key={s.k} className="rounded-xl bg-slate-50 px-3 py-2.5 dark:bg-slate-800/60">
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{s.k}
</dt>
<dd className="mt-0.5 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
{fmt(s.v, activeKpi.unit)}
</dd>
</div>
))}
</dl>
</div>
<div className="min-w-0 flex-1">
<div className="mb-3 flex items-center gap-4 text-[11px] font-medium text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-0.5 w-4 rounded-full bg-indigo-500" />
{rangeMeta.long}
</span>
{compare && (
<span className="inline-flex items-center gap-1.5">
<span
aria-hidden="true"
className="h-0.5 w-4 rounded-full bg-slate-300 dark:bg-slate-600"
style={{ backgroundImage: "repeating-linear-gradient(90deg,currentColor 0 3px,transparent 3px 6px)" }}
/>
Prior period
</span>
)}
</div>
<div className="relative h-40 w-full rounded-xl bg-slate-50 p-3 dark:bg-slate-800/50">
<svg
viewBox="0 0 100 32"
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
role="img"
aria-label={`${activeKpi.label} over the ${rangeMeta.long}: peak ${fmt(summary.peak, activeKpi.unit)}, low ${fmt(summary.low, activeKpi.unit)}, average ${fmt(summary.avg, activeKpi.unit)}, ending at ${fmt(activeChart.period.value, activeKpi.unit)}.`}
>
{[8, 16, 24].map((y) => (
<line
key={y}
x1="0"
x2="100"
y1={y}
y2={y}
className="stroke-slate-200 dark:stroke-slate-700"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
{compare && (
<path
key={`${activeKpi.id}-${range}-ghost`}
d={linePath(activeChart.previous, activeChart.min, activeChart.max)}
className="dkr-ghost fill-none stroke-slate-400/80 dark:stroke-slate-500"
strokeWidth={1.5}
strokeDasharray="3 3"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
<path
key={`${activeKpi.id}-${range}-line`}
d={linePath(activeChart.current, activeChart.min, activeChart.max)}
className="dkr-line fill-none stroke-indigo-500 dark:stroke-indigo-400"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
</div>
</div>
</div>
<p aria-live="polite" className="sr-only">
{`${activeKpi.label} selected. Showing the ${rangeMeta.long}${compare ? ", compared with the prior period" : ""}.`}
</p>
</div>
</section>
);
}
function Card({
kpi,
index,
uid,
range,
rangeLong,
compare,
selected,
reduce,
chart,
ring,
onSelect,
tabRef,
}: {
kpi: Kpi;
index: number;
uid: string;
range: RangeId;
rangeLong: string;
compare: boolean;
selected: boolean;
reduce: boolean;
chart: { current: number[]; previous: number[]; min: number; max: number; period: Period };
ring: string;
onSelect: () => void;
tabRef: (el: HTMLButtonElement | null) => void;
}) {
const { current, previous, min, max, period } = chart;
const up = period.delta >= 0;
const good = kpi.lowerIsBetter ? !up : up;
const shown = useCountUp(period.value, !reduce);
const dot = endPoint(current, min, max);
const gid = `${uid}-grad-${kpi.id}`;
const area = `${linePath(current, min, max)} L100,32 L0,32 Z`;
const tone = good
? "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25"
: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25";
return (
<motion.div
role="presentation"
initial={reduce ? false : { opacity: 0, y: 14 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.45, ease: "easeOut", delay: reduce ? 0 : index * 0.07 }}
>
<button
ref={tabRef}
type="button"
role="tab"
id={`${uid}-tab-${kpi.id}`}
aria-selected={selected}
aria-controls={`${uid}-panel-${kpi.id}`}
tabIndex={selected ? 0 : -1}
onClick={onSelect}
className={`group relative flex h-full w-full flex-col overflow-hidden rounded-2xl border bg-white p-5 text-left shadow-sm transition-colors dark:bg-slate-900 ${ring} ${
selected
? "border-indigo-500/60 ring-1 ring-indigo-500/25 dark:border-indigo-400/60 dark:ring-indigo-400/25"
: "border-slate-200 hover:border-slate-300 dark:border-slate-800 dark:hover:border-slate-700"
}`}
>
<span
aria-hidden="true"
className={`absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-400 transition-opacity ${
selected ? "opacity-100" : "opacity-0 group-hover:opacity-50"
}`}
/>
<span className="flex items-start justify-between gap-3">
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`grid h-7 w-7 place-items-center rounded-lg transition-colors ${
selected
? "bg-indigo-500/10 text-indigo-600 dark:bg-indigo-400/15 dark:text-indigo-300"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
}`}
>
<span className="block h-4 w-4">{kpi.icon}</span>
</span>
<span className="text-xs font-medium text-slate-600 dark:text-slate-400">{kpi.label}</span>
</span>
</span>
<span className="mt-4 block text-2xl font-semibold tabular-nums tracking-tight text-slate-900 dark:text-white">
{fmt(shown, kpi.unit)}
</span>
<motion.span
key={`${range}-${period.delta}`}
initial={reduce ? false : { opacity: 0, y: -4 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="mt-2 flex items-center gap-2"
>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums ring-1 ring-inset ${tone}`}>
<Arrow up={up} />
{`${up ? "+" : "−"}${Math.abs(period.delta).toFixed(1)}%`}
</span>
<span className="text-[11px] text-slate-500 dark:text-slate-500">vs prior</span>
<span className="sr-only">
{`${Math.abs(period.delta).toFixed(1)} percent ${up ? "increase" : "decrease"} versus the preceding ${rangeLong}, which is ${good ? "an improvement" : "a decline"}.`}
</span>
</motion.span>
<span className="relative mt-5 block h-9 w-full">
<svg viewBox="0 0 100 32" preserveAspectRatio="none" className="h-full w-full" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" className="text-indigo-500 dark:text-indigo-400" stopColor="currentColor" stopOpacity="0.28" />
<stop offset="100%" className="text-indigo-500 dark:text-indigo-400" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
{compare && (
<path
key={`${kpi.id}-${range}-c-ghost`}
d={linePath(previous, min, max)}
className="dkr-ghost fill-none stroke-slate-300 dark:stroke-slate-600"
strokeWidth={1.5}
strokeDasharray="3 3"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
<path key={`${kpi.id}-${range}-c-area`} d={area} className="dkr-area" fill={`url(#${gid})`} />
<path
key={`${kpi.id}-${range}-c-line`}
d={linePath(current, min, max)}
className="dkr-line fill-none stroke-indigo-500 dark:stroke-indigo-400"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
vectorEffect="non-scaling-stroke"
/>
</svg>
<span
aria-hidden="true"
className="pointer-events-none absolute h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-indigo-500 dark:bg-indigo-400"
style={{ left: `${dot.left}%`, top: `${dot.top}%` }}
>
<span className="dkr-halo absolute inset-0 rounded-full bg-indigo-500 dark:bg-indigo-400" />
</span>
</span>
</button>
</motion.div>
);
}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 Stat Widgets
Originalassorted dashboard stat widgets

Dashboard Activity
Originalrecent activity feed widget

Dashboard Chart Widget
Originalchart panel widget with tabs

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.

