"use client";
import * as React from "react";
export type SparklineVariant = "line" | "area" | "bar";
export interface SparklineProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/** Values, oldest first. */
data: number[];
variant?: SparklineVariant;
/** Fixed lower bound. Auto (data minimum) when omitted. */
min?: number;
/** Fixed upper bound. Auto (data maximum) when omitted. */
max?: number;
/** Monotone curve (never overshoots the data) or straight segments. */
curve?: "smooth" | "linear";
/** CSS width; any length works — "100%" makes it responsive. */
width?: number | string;
/** CSS height; the default matches the surrounding text. */
height?: number | string;
/** Line width in px. */
strokeWidth?: number;
/** Dot on the latest value. */
showDot?: boolean;
/** Latest value and change printed after the chart. */
showValue?: boolean;
/** Colour by trend direction or always use the accent. */
colorBy?: "trend" | "accent";
accent?: string;
upColor?: string;
downColor?: string;
/** Draw in when scrolled into view. */
animate?: boolean;
/** Draw-in length in ms. */
duration?: number;
/** Hover / focus tooltip with the nearest value. */
interactive?: boolean;
/** Metric name, used in the accessible summary, e.g. "Revenue". */
label?: string;
/** What one data point represents, plural, for the summary: "over 30 days". */
period?: string;
/** Optional label per point (e.g. dates), shown in the tooltip. */
labels?: string[];
locale?: string;
formatValue?: (value: number) => string;
}
/* SSR-safe: renders "no preference" on the server and the first client pass, so markup always hydrates. */
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/** viewBox is 100 × 100 with preserveAspectRatio="none"; strokes are non-scaling, the dot is HTML. */
const PAD_Y = 10;
const r2 = (n: number) => Math.round(n * 100) / 100;
/** Monotone cubic interpolation (Fritsch–Carlson): smooth, but never overshoots the data. */
function monotonePath(points: [number, number][]) {
const n = points.length;
if (n < 2) return n ? `M${points[0][0]},${points[0][1]}` : "";
const dx: number[] = [];
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
dx[i] = points[i + 1][0] - points[i][0];
slope[i] = (points[i + 1][1] - points[i][1]) / (dx[i] || 1);
}
const tangent: number[] = [slope[0]];
for (let i = 1; i < n - 1; i++) {
tangent[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / slope[i - 1] + (dx[i] + 2 * dx[i - 1]) / slope[i]);
}
tangent[n - 1] = slope[n - 2];
let d = `M${r2(points[0][0])},${r2(points[0][1])}`;
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = points[i];
const [x1, y1] = points[i + 1];
const h = dx[i] / 3;
d += `C${r2(x0 + h)},${r2(y0 + tangent[i] * h)},${r2(x1 - h)},${r2(y1 - tangent[i + 1] * h)},${r2(x1)},${r2(y1)}`;
}
return d;
}
function linearPath(points: [number, number][]) {
return points.map(([x, y], i) => `${i ? "L" : "M"}${r2(x)},${r2(y)}`).join("");
}
/**
* A word-sized chart for tables and running text: line, area or bars, coloured by trend with
* an arrow and a computed text summary, a dot on the latest value, a nearest-value tooltip on
* hover, tap and keyboard focus, and a draw-in when it scrolls into view.
*/
export function Sparkline({
data,
variant = "line",
min,
max,
curve = "smooth",
width = "6em",
height = "1.25em",
strokeWidth = 1.5,
showDot = true,
showValue = false,
colorBy = "trend",
accent = "#FF7448",
upColor = "#2FBF71",
downColor = "#E5484D",
animate = true,
duration = 900,
interactive = true,
label,
period = "days",
labels,
locale = "en-US",
formatValue,
className = "",
style,
...props
}: SparklineProps) {
const reduceMotion = usePrefersReducedMotion();
const chartRef = React.useRef<HTMLSpanElement>(null);
const cursorRef = React.useRef<number | null>(null);
const touchTimer = React.useRef(0);
const gradientId = `sp-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [inView, setInView] = React.useState(false);
const [cursor, setCursor] = React.useState<number | null>(null);
const [announce, setAnnounce] = React.useState("");
const values = data.filter((v) => Number.isFinite(v));
const n = values.length;
const lo = min ?? (n ? Math.min(...values) : 0);
const hiRaw = max ?? (n ? Math.max(...values) : 1);
const hi = hiRaw === lo ? lo + 1 : hiRaw;
const fmt = (v: number) =>
formatValue ? formatValue(v) : new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(v);
// Trend: first → last.
const first = values[0] ?? 0;
const last = values[n - 1] ?? 0;
const change = first !== 0 ? ((last - first) / Math.abs(first)) * 100 : last - first === 0 ? 0 : null;
const direction = change === null ? (last > first ? "up" : "down") : Math.abs(change) < 0.5 ? "flat" : change > 0 ? "up" : "down";
const pct = change === null ? null : new Intl.NumberFormat(locale, { maximumFractionDigits: Math.abs(change) < 10 ? 1 : 0 }).format(Math.abs(change));
const summary = n
? `${label ? `${label}: ` : ""}${
direction === "flat"
? "Trend flat"
: `Trend ${direction} ${pct !== null ? `${pct}%` : `by ${fmt(Math.abs(last - first))}`}`
} over ${n} ${period}, latest ${fmt(last)}${n > 1 ? `, range ${fmt(Math.min(...values))} to ${fmt(Math.max(...values))}` : ""}`
: `${label ? `${label}: ` : ""}No data`;
const color = colorBy === "accent" ? accent : direction === "up" ? upColor : direction === "down" ? downColor : "#8a8a93";
const yOf = (v: number) => PAD_Y + (1 - (Math.min(hi, Math.max(lo, v)) - lo) / (hi - lo)) * (100 - PAD_Y * 2);
const xOf = (i: number) => (variant === "bar" ? ((i + 0.5) / n) * 100 : n > 1 ? (i / (n - 1)) * 100 : 50);
const points: [number, number][] = values.map((v, i) => [xOf(i), yOf(v)]);
const linePath = curve === "smooth" ? monotonePath(points) : linearPath(points);
const bottom = 100 - PAD_Y / 2;
const areaPath = n > 1 ? `${linePath}L${r2(points[n - 1][0])},${bottom}L${r2(points[0][0])},${bottom}Z` : "";
// Draw in once, when the chart first scrolls into view.
React.useEffect(() => {
const el = chartRef.current;
if (!el || !animate) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) {
setInView(true);
observer.disconnect();
}
});
observer.observe(el);
return () => observer.disconnect();
}, [animate]);
React.useEffect(() => () => window.clearTimeout(touchTimer.current), []);
const drawn = !animate || reduceMotion || inView;
const transition = animate && !reduceMotion ? `${Math.max(0, duration)}ms` : "0ms";
const moveCursor = (index: number | null) => {
if (cursorRef.current === index) return;
cursorRef.current = index;
setCursor(index);
};
/** Nearest point to the pointer — state only changes when the index does. */
const indexAt = (clientX: number) => {
const el = chartRef.current;
if (!el || !n) return null;
const rect = el.getBoundingClientRect();
const t = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return variant === "bar" ? Math.min(n - 1, Math.floor(t * n)) : Math.round(t * (n - 1));
};
const pointLabel = (i: number) => labels?.[i] ?? `#${i + 1}`;
const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
if (!n) return;
const currentIndex = cursorRef.current ?? n - 1;
let next: number | null = null;
if (event.key === "ArrowLeft") next = Math.max(0, currentIndex - 1);
else if (event.key === "ArrowRight") next = Math.min(n - 1, currentIndex + 1);
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = n - 1;
else if (event.key === "Escape") {
moveCursor(null);
return;
}
if (next === null) return;
event.preventDefault();
moveCursor(next);
setAnnounce(`${pointLabel(next)}: ${fmt(values[next])}`);
};
const active = interactive && cursor !== null && cursor < n ? cursor : null;
const dotIndex = active ?? (showDot && n ? n - 1 : null);
const arrow = direction === "up" ? "▲" : direction === "down" ? "▼" : "–";
return (
<span
className={`relative inline-flex items-center gap-1.5 align-middle [--sp-ink:color-mix(in_oklab,var(--sp-color)_72%,#18181b)] dark:[--sp-ink:var(--sp-color)] ${className}`}
style={{ ["--sp-color" as string]: color, ...style }}
{...props}
>
<span
ref={chartRef}
role="img"
aria-label={summary}
tabIndex={interactive ? 0 : undefined}
className={`relative inline-block min-w-0 rounded-[3px] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sp-color)] ${
interactive ? "cursor-crosshair touch-pan-y" : ""
}`}
style={{ width, height }}
onPointerMove={interactive ? (e) => moveCursor(indexAt(e.clientX)) : undefined}
onPointerDown={
interactive
? (e) => {
moveCursor(indexAt(e.clientX));
if (e.pointerType !== "mouse") {
// Touch has no hover: keep the tooltip briefly after a tap.
window.clearTimeout(touchTimer.current);
touchTimer.current = window.setTimeout(() => moveCursor(null), 2200);
}
}
: undefined
}
onPointerLeave={interactive ? (e) => e.pointerType === "mouse" && moveCursor(null) : undefined}
onKeyDown={interactive ? handleKeyDown : undefined}
onFocus={interactive ? (e) => e.currentTarget.matches(":focus-visible") && moveCursor(n - 1) : undefined}
onBlur={interactive ? () => moveCursor(null) : undefined}
>
<svg
aria-hidden="true"
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 size-full overflow-visible"
style={{
// Line and area draw in left to right; bars grow individually below.
clipPath: variant === "bar" || drawn ? "inset(-50% -50% -50% -50%)" : "inset(-50% 100% -50% -50%)",
transition: variant === "bar" ? undefined : `clip-path ${transition} cubic-bezier(0.65, 0, 0.35, 1)`,
}}
>
{variant === "area" && n > 1 ? (
<>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" style={{ stopColor: "var(--sp-color)", stopOpacity: 0.28 }} />
<stop offset="100%" style={{ stopColor: "var(--sp-color)", stopOpacity: 0 }} />
</linearGradient>
</defs>
<path d={areaPath} fill={`url(#${gradientId})`} />
</>
) : null}
{variant === "bar"
? values.map((v, i) => {
const slot = 100 / n;
const zero = yOf(Math.min(hi, Math.max(lo, 0)));
const base = lo >= 0 ? bottom : zero;
const y = Math.min(base, yOf(v));
const h = Math.max(1, Math.abs(base - yOf(v)));
return (
<rect
key={i}
x={r2(i * slot + slot * 0.14)}
y={r2(y)}
width={r2(slot * 0.72)}
height={r2(h)}
className="fill-[var(--sp-color)]"
fillOpacity={active === i || (active === null && i === n - 1) ? 1 : 0.45}
style={{
transformBox: "fill-box",
transformOrigin: v >= 0 ? "bottom" : "top",
transform: drawn ? "scaleY(1)" : "scaleY(0)",
transition: `transform ${transition} cubic-bezier(0.22, 1, 0.36, 1) ${animate && !reduceMotion ? Math.round((i / n) * Math.max(0, duration) * 0.5) : 0}ms, fill-opacity 150ms`,
}}
/>
);
})
: (
<path
d={linePath}
fill="none"
className="stroke-[var(--sp-color)]"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
{/* The dot is HTML so it stays round however the chart is stretched. */}
{dotIndex !== null && variant !== "bar" ? (
<span
aria-hidden="true"
className="pointer-events-none absolute rounded-full bg-[var(--sp-color)] ring-2 ring-white transition-opacity duration-300 dark:ring-zinc-950"
style={{
left: `${r2(points[dotIndex][0])}%`,
top: `${r2(points[dotIndex][1])}%`,
width: strokeWidth * 2 + 3,
height: strokeWidth * 2 + 3,
transform: "translate(-50%, -50%)",
opacity: drawn ? 1 : 0,
transitionDelay: drawn && active === null ? transition : "0ms",
}}
/>
) : null}
{active !== null ? (
<>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px bg-zinc-900/20 dark:bg-white/25"
style={{ left: `${r2(points[active][0])}%` }}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-full z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md border border-zinc-900/10 bg-white px-1.5 py-1 font-mono text-[10px] leading-none tracking-[0.04em] text-zinc-900 shadow-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100"
style={{ left: `${r2(points[active][0])}%` }}
>
<span className="text-zinc-500 dark:text-zinc-400">{pointLabel(active)}</span> {fmt(values[active])}
</span>
</>
) : null}
</span>
{showValue && n ? (
<span aria-hidden="true" className="inline-flex items-baseline gap-1 whitespace-nowrap font-mono text-[0.8em] tabular-nums">
<span className="text-zinc-900 dark:text-zinc-100">{fmt(last)}</span>
<span className="text-[var(--sp-ink)]">
{arrow}
{pct !== null ? `${pct}%` : ""}
</span>
</span>
) : null}
{/* Keyboard exploration is announced politely; hover is not, to avoid chatter. */}
<span className="sr-only" aria-live="polite">
{announce}
</span>
</span>
);
}