Chart Sparkline
Original · freeinline sparklines in stat rows
byWeb InnoventixReact + Tailwind
chartsparklinecharts
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-sparkline.jsonchart-sparkline.tsx
"use client";
import {
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type RangeKey = "7D" | "30D" | "90D";
type Unit = "currency" | "number" | "percent" | "duration";
interface MetricDef {
id: string;
label: string;
hint: string;
unit: Unit;
seed: number;
base: number;
volatility: number;
trend: number;
goodWhenDown: boolean;
}
const METRICS: MetricDef[] = [
{
id: "mrr",
label: "Recurring revenue",
hint: "Net new plus expansion",
unit: "currency",
seed: 1971,
base: 46200,
volatility: 1500,
trend: 165,
goodWhenDown: false,
},
{
id: "accounts",
label: "Active accounts",
hint: "Workspaces active this week",
unit: "number",
seed: 5503,
base: 1180,
volatility: 66,
trend: 6.4,
goodWhenDown: false,
},
{
id: "conversion",
label: "Trial conversion",
hint: "Signup to paid plan",
unit: "percent",
seed: 8123,
base: 3.1,
volatility: 0.42,
trend: 0.018,
goodWhenDown: false,
},
{
id: "session",
label: "Median session",
hint: "Time in app per visit",
unit: "duration",
seed: 3390,
base: 236,
volatility: 24,
trend: 1.05,
goodWhenDown: false,
},
{
id: "bounce",
label: "Bounce rate",
hint: "Left within ten seconds",
unit: "percent",
seed: 6642,
base: 44,
volatility: 2.2,
trend: -0.11,
goodWhenDown: true,
},
{
id: "backlog",
label: "Support backlog",
hint: "Tickets open over a day",
unit: "number",
seed: 9007,
base: 72,
volatility: 8,
trend: -0.42,
goodWhenDown: true,
},
];
const RANGE_OPTIONS: { value: RangeKey; label: string }[] = [
{ value: "7D", label: "7D" },
{ value: "30D", label: "30D" },
{ value: "90D", label: "90D" },
];
const RANGE_COUNT: Record<RangeKey, number> = { "7D": 7, "30D": 30, "90D": 90 };
const MASTER_LEN = 90;
const W = 300;
const H = 64;
const PAD = 0.16;
// Deterministic Lehmer PRNG so server and client render identical series.
function buildSeries(def: MetricDef, len: number): number[] {
let s = def.seed % 2147483647;
if (s <= 0) s += 2147483646;
const next = (): number => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
const out: number[] = [];
let v = def.base;
for (let i = 0; i < len; i += 1) {
v = v + (next() - 0.5) * def.volatility + def.trend;
if (v < 0) v = Math.abs(v) * 0.5 + 1;
out.push(v);
}
return out;
}
function formatValue(unit: Unit, v: number): string {
switch (unit) {
case "currency":
return `$${Math.round(v).toLocaleString("en-US")}`;
case "number":
return Math.round(v).toLocaleString("en-US");
case "percent":
return `${v.toFixed(1)}%`;
case "duration": {
const total = Math.round(v);
const m = Math.floor(total / 60);
const sec = total % 60;
return `${m}m ${sec.toString().padStart(2, "0")}s`;
}
default:
return String(v);
}
}
function whenLabel(daysAgo: number): string {
if (daysAgo <= 0) return "Today";
if (daysAgo === 1) return "Yesterday";
return `${daysAgo} days ago`;
}
function TrendArrow({ up }: { up: boolean }) {
return (
<svg
viewBox="0 0 12 12"
className="h-3 w-3"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
{up ? (
<path d="M6 9.5V2.5M6 2.5L3 5.5M6 2.5L9 5.5" />
) : (
<path d="M6 2.5V9.5M6 9.5L3 6.5M6 9.5L9 6.5" />
)}
</svg>
);
}
interface AccentTheme {
text: string;
dot: string;
ring: string;
cross: string;
badge: string;
}
const ACCENT_GOOD: AccentTheme = {
text: "text-emerald-500 dark:text-emerald-400",
dot: "bg-emerald-500 dark:bg-emerald-400",
ring: "bg-emerald-500/40 dark:bg-emerald-400/40",
cross: "bg-emerald-400/50 dark:bg-emerald-400/40",
badge:
"bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
};
const ACCENT_BAD: AccentTheme = {
text: "text-rose-500 dark:text-rose-400",
dot: "bg-rose-500 dark:bg-rose-400",
ring: "bg-rose-500/40 dark:bg-rose-400/40",
cross: "bg-rose-400/50 dark:bg-rose-400/40",
badge:
"bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
};
interface SegProps {
value: RangeKey;
onChange: (v: RangeKey) => void;
}
function SegmentedControl({ value, onChange }: SegProps) {
const reduce = useReducedMotion();
const pillId = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const idx = RANGE_OPTIONS.findIndex((o) => o.value === value);
const move = (to: number) => {
const clamped = (to + RANGE_OPTIONS.length) % RANGE_OPTIONS.length;
onChange(RANGE_OPTIONS[clamped].value);
refs.current[clamped]?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
move(idx + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
move(idx - 1);
} else if (e.key === "Home") {
e.preventDefault();
move(0);
} else if (e.key === "End") {
e.preventDefault();
move(RANGE_OPTIONS.length - 1);
}
};
return (
<div
role="radiogroup"
aria-label="Time range"
className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800/80"
>
{RANGE_OPTIONS.map((o, i) => {
const selected = o.value === value;
return (
<button
key={o.value}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
aria-label={`${o.label}, last ${RANGE_COUNT[o.value]} days`}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={onKeyDown}
className={`relative rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-800 ${
selected
? "text-slate-900 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{selected && (
<motion.span
layoutId={`spk-seg-active-${pillId}`}
aria-hidden="true"
className="absolute inset-0 rounded-md bg-white shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-950 dark:ring-white/10"
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 420, damping: 34 }
}
/>
)}
<span className="relative z-10">{o.label}</span>
</button>
);
})}
</div>
);
}
interface StatRowProps {
def: MetricDef;
series: number[];
range: RangeKey;
index: number;
reduce: boolean | null;
}
function StatRow({ def, series, range, index, reduce }: StatRowProps) {
const gradId = useId();
const wrapRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<number | null>(null);
const [focused, setFocused] = useState(false);
const view = useMemo(() => {
const n = series.length;
const min = Math.min(...series);
const max = Math.max(...series);
const span = max - min || 1;
const pts = series.map((v, i) => {
const xf = n === 1 ? 0 : i / (n - 1);
const yn = (v - min) / span;
const yf = 1 - (PAD + yn * (1 - 2 * PAD));
return { xf, yf, v };
});
const line = pts
.map(
(p, i) =>
`${i === 0 ? "M" : "L"}${(p.xf * W).toFixed(2)} ${(p.yf * H).toFixed(2)}`,
)
.join(" ");
const area = `${line} L${W} ${H} L0 ${H} Z`;
return { n, pts, line, area };
}, [series]);
const first = series[0];
const last = series[series.length - 1];
const pct = first === 0 ? 0 : ((last - first) / Math.abs(first)) * 100;
const up = pct >= 0;
const good = up ? !def.goodWhenDown : def.goodWhenDown;
const accent = good ? ACCENT_GOOD : ACCENT_BAD;
const currentText = formatValue(def.unit, last);
const deltaText = `${up ? "+" : "−"}${Math.abs(pct).toFixed(1)}%`;
const lastPoint = view.pts[view.pts.length - 1];
const setFromClientX = (clientX: number) => {
const el = wrapRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0) return;
const frac = (clientX - rect.left) / rect.width;
const i = Math.round(frac * (view.n - 1));
setActive(Math.max(0, Math.min(view.n - 1, i)));
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
setFromClientX(e.clientX);
};
// Touch taps fire pointerdown/up with no preceding move, so hover alone
// would leave the advertised "tap" interaction dead on mobile.
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
setFromClientX(e.clientX);
};
const onPointerLeave = () => {
if (!focused) setActive(null);
};
const onFocus = () => {
setFocused(true);
setActive((p) => (p === null ? view.n - 1 : p));
};
const onBlur = () => {
setFocused(false);
setActive(null);
};
const step = (delta: number) => {
setActive((p) => {
const from = p === null ? view.n - 1 : Math.min(view.n - 1, Math.max(0, p));
return Math.max(0, Math.min(view.n - 1, from + delta));
});
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
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(view.n - 1);
} else if (e.key === "Escape") {
setActive(null);
wrapRef.current?.blur();
}
};
// `active` indexes `series`, which shrinks when the range changes, so a stale
// index from a wider range must be clamped back into bounds before use.
const activeIndex =
active === null ? null : Math.max(0, Math.min(view.n - 1, active));
const activePoint = activeIndex === null ? null : view.pts[activeIndex];
const activeDaysAgo = activeIndex === null ? 0 : view.n - 1 - activeIndex;
const tipLeft = activePoint
? Math.min(0.9, Math.max(0.1, activePoint.xf)) * 100
: 0;
const liveText =
activePoint === null
? ""
: `${def.label}, ${whenLabel(activeDaysAgo)}: ${formatValue(def.unit, activePoint.v)}`;
const groupLabel = `${def.label} trend over the last ${view.n} days. Currently ${currentText}, ${
up ? "up" : "down"
} ${Math.abs(pct).toFixed(1)} percent versus ${view.n} days ago. Use the arrow keys to inspect each day.`;
return (
<motion.li
initial={reduce ? false : { opacity: 0, y: 12 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, delay: index * 0.05, ease: "easeOut" }}
className="flex flex-wrap items-center gap-x-4 gap-y-3 rounded-lg px-2 py-4 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40"
>
{/* Label */}
<div className="w-40 shrink-0">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{def.label}
</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
{def.hint}
</p>
</div>
{/* Value + delta */}
<div className="order-2 ml-auto flex items-center justify-end gap-3 sm:order-3 sm:ml-0 sm:w-40">
<span className="text-sm font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{currentText}
</span>
<span
role="img"
aria-label={`${up ? "Up" : "Down"} ${Math.abs(pct).toFixed(1)} percent, ${
good ? "improving" : "needs attention"
}`}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${accent.badge}`}
>
<TrendArrow up={up} />
{deltaText}
</span>
</div>
{/* Sparkline */}
<div className="relative order-3 w-full min-w-0 sm:order-2 sm:w-auto sm:flex-1">
<div
ref={wrapRef}
role="group"
tabIndex={0}
aria-label={groupLabel}
aria-roledescription="interactive sparkline"
onPointerMove={onPointerMove}
onPointerDown={onPointerDown}
onPointerLeave={onPointerLeave}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
className={`relative h-14 w-full cursor-crosshair touch-pan-y rounded-md 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-indigo-400 dark:focus-visible:ring-offset-slate-900 ${accent.text}`}
>
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="h-full w-full overflow-visible"
aria-hidden="true"
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.26} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<motion.g
key={`${def.id}-${range}`}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: reduce ? 0 : 0.5, ease: "easeOut" }}
>
<path d={view.area} fill={`url(#${gradId})`} stroke="none" />
<path
d={view.line}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</motion.g>
</svg>
{/* Resting marker at the latest point */}
<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 ${accent.dot} ${
activePoint ? "opacity-0" : "opacity-100"
}`}
style={{ left: "100%", top: `${lastPoint.yf * 100}%` }}
/>
{/* Active crosshair + marker */}
{activePoint && (
<>
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-y-1 w-px ${accent.cross}`}
style={{ left: `${activePoint.xf * 100}%` }}
/>
{!reduce && (
<span
aria-hidden="true"
className={`pointer-events-none absolute h-3 w-3 rounded-full spk-ping ${accent.ring}`}
style={{
left: `${activePoint.xf * 100}%`,
top: `${activePoint.yf * 100}%`,
}}
/>
)}
<span
aria-hidden="true"
className={`pointer-events-none absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-sm dark:border-slate-900 ${accent.dot}`}
style={{
left: `${activePoint.xf * 100}%`,
top: `${activePoint.yf * 100}%`,
}}
/>
</>
)}
</div>
{/* Tooltip */}
{activePoint && (
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-20 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white shadow-lg dark:bg-slate-100 dark:text-slate-900"
style={{ left: `${tipLeft}%` }}
>
<span className="tabular-nums">
{formatValue(def.unit, activePoint.v)}
</span>
<span className="ml-1.5 font-medium text-slate-400 dark:text-slate-500">
{whenLabel(activeDaysAgo)}
</span>
</div>
)}
<span className="sr-only" role="status" aria-live="polite">
{liveText}
</span>
</div>
</motion.li>
);
}
export default function ChartSparkline() {
const reduce = useReducedMotion();
const [range, setRange] = useState<RangeKey>("30D");
const masters = useMemo(() => {
const m: Record<string, number[]> = {};
for (const def of METRICS) m[def.id] = buildSeries(def, MASTER_LEN);
return m;
}, []);
const count = RANGE_COUNT[range];
return (
<section className="relative w-full bg-white px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes spk-ping {
0% { opacity: 0.55; transform: translate(-50%, -50%) scale(0.55); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(2.1); }
}
.spk-ping { animation: spk-ping 1.5s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.spk-ping { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-4xl">
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900/50">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight text-slate-900 sm:text-xl dark:text-white">
Product analytics
</h2>
<p className="mt-1 max-w-md text-sm text-slate-500 dark:text-slate-400">
Trailing window against the prior period. Hover, tap, or focus any
trend line to inspect an individual day.
</p>
<div className="mt-3 flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
Improving
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-rose-500" aria-hidden="true" />
Needs attention
</span>
</div>
</div>
<SegmentedControl value={range} onChange={setRange} />
</div>
{/* Stat rows */}
<ul className="mt-6 divide-y divide-slate-100 dark:divide-slate-800/80">
{METRICS.map((def, i) => (
<StatRow
key={def.id}
def={def}
series={masters[def.id].slice(MASTER_LEN - count)}
range={range}
index={i}
reduce={reduce}
/>
))}
</ul>
<p className="mt-6 border-t border-slate-100 pt-4 text-xs text-slate-400 dark:border-slate-800 dark:text-slate-500">
Sample data shown for demonstration. Every figure recomputes when you
change the time range above.
</p>
</div>
</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 Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

