An inline line spark that draws in on view, with gradient area fill, end-point dot, and up/down tinting.
npx shadcn@latest add @paragon/sparkline"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const TREND_COLORS = {
up: "oklch(0.66 0.14 160)",
down: "oklch(0.62 0.18 25)",
neutral: "currentColor",
} as const;
export interface SparklineProps
extends Omit<React.ComponentProps<"svg">, "children"> {
/** Series values, oldest first. */
data: number[];
width?: number;
height?: number;
strokeWidth?: number;
/** Tints the line — green for up, red for down, currentColor otherwise. */
trend?: "up" | "down" | "neutral";
/** Explicit stroke color. Overrides `trend`. */
color?: string;
/** Gradient area fill under the line, fading to transparent. */
fillArea?: boolean;
/** Dot marking the most recent point. */
endDot?: boolean;
/** Hollow markers on the min and max points. */
showExtremes?: boolean;
/** Optional labels for the x-axis, aligned to each value (used in tooltip). */
labels?: string[];
/** Formats the value shown in the hover tooltip. */
formatValue?: (value: number) => string;
/** Renders the final state immediately, no draw-in. */
static?: boolean;
}
/**
* An inline line spark for metric rows and stat cards. The stroke draws in
* once when scrolled into view (stroke-dashoffset via a normalized
* pathLength), then the area fill and end dot fade up. Hovering — or focusing
* with the keyboard — snaps a crosshair to the nearest point and opens a
* value tooltip; every coordinate is computed from a linear scale, never
* eyeballed. Under prefers-reduced-motion the final state renders immediately.
*/
export function Sparkline({
data,
width = 96,
height = 32,
strokeWidth = 1.5,
trend = "neutral",
color,
fillArea = true,
endDot = true,
showExtremes = false,
labels,
formatValue = (v) => v.toLocaleString("en-US"),
static: isStatic = false,
className,
"aria-label": ariaLabel,
...props
}: SparklineProps) {
const ref = React.useRef<SVGSVGElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const gradientId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const stroke = color ?? TREND_COLORS[trend];
const hasData = data.length > 0;
const values = data.length === 1 ? [data[0], data[0]] : data;
const n = values.length;
const [active, setActive] = React.useState<number | null>(null);
// --- Scales (every coordinate computed, never eyeballed) -----------------
const pad = Math.max(strokeWidth, endDot || showExtremes ? 3 : 1) + 0.5;
const min = hasData ? Math.min(...values) : 0;
const max = hasData ? Math.max(...values) : 1;
const range = max - min || 1;
const x = React.useCallback(
(i: number) => pad + (n === 1 ? 0 : (i / (n - 1)) * (width - pad * 2)),
[pad, n, width],
);
const y = React.useCallback(
(v: number) => height - pad - ((v - min) / range) * (height - pad * 2),
[height, pad, min, range],
);
const points = React.useMemo(
() => values.map((v, i) => ({ x: x(i), y: y(v), v, i })),
[values, x, y],
);
const line = points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y}`).join("");
const area = `${line}L${x(n - 1)} ${height}L${x(0)} ${height}Z`;
const last = hasData ? values[n - 1] : 0;
// Extremes on the ORIGINAL series (skip the duplicated single-point case).
const minIdx = React.useMemo(
() => (hasData ? values.indexOf(min) : -1),
[hasData, values, min],
);
const maxIdx = React.useMemo(
() => (hasData ? values.indexOf(max) : -1),
[hasData, values, max],
);
const pickNearest = React.useCallback(
(clientX: number) => {
const svg = ref.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const localX = ((clientX - rect.left) / rect.width) * width;
let best = 0;
let bestDist = Infinity;
for (const p of points) {
const d = Math.abs(p.x - localX);
if (d < bestDist) {
bestDist = d;
best = p.i;
}
}
setActive(best);
},
[points, width],
);
const activePoint = active !== null ? points[active] : null;
const activeLabel =
active !== null && labels ? labels[Math.min(active, labels.length - 1)] : null;
if (!hasData) {
return (
<div
role="img"
aria-label="No data"
className={cn(
"flex shrink-0 items-center justify-center rounded-md bg-current/[0.04] text-[10px] text-muted-foreground",
className,
)}
style={{ width, height }}
>
No data
</div>
);
}
return (
<span
className={cn("relative inline-flex shrink-0", className)}
style={{ width, height }}
>
<svg
ref={ref}
role="img"
tabIndex={0}
aria-label={
ariaLabel ??
`Sparkline of ${data.length} values, latest ${last.toLocaleString("en-US")}`
}
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
data-slot="sparkline"
className="overflow-visible outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background rounded-[3px]"
onPointerMove={(e) => pickNearest(e.clientX)}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(n - 1)}
onBlur={() => setActive(null)}
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
setActive((prev) => {
const cur = prev ?? n - 1;
const next = e.key === "ArrowRight" ? cur + 1 : cur - 1;
return Math.max(0, Math.min(n - 1, next));
});
}
}}
{...props}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity={0.2} />
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
</linearGradient>
</defs>
{fillArea && (
<path
d={area}
fill={`url(#${gradientId})`}
aria-hidden
style={{
opacity: drawn ? 1 : 0,
transition: animate
? "opacity 300ms var(--ease-out) 250ms"
: undefined,
}}
/>
)}
<path
d={line}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
pathLength={1}
strokeDasharray={1}
style={{
strokeDashoffset: drawn ? 0 : 1,
transition: animate
? "stroke-dashoffset 600ms var(--ease-out)"
: undefined,
}}
/>
{/* Min / max hollow markers */}
{showExtremes &&
[minIdx, maxIdx].map((idx, k) =>
idx >= 0 ? (
<circle
key={k}
cx={x(idx)}
cy={y(values[idx])}
r={2.25}
fill="var(--color-card)"
stroke={stroke}
strokeWidth={1.25}
vectorEffect="non-scaling-stroke"
aria-hidden
style={{
opacity: drawn ? 0.9 : 0,
transition: animate
? "opacity 200ms var(--ease-out) 450ms"
: undefined,
}}
/>
) : null,
)}
{endDot && (
<circle
cx={x(n - 1)}
cy={y(last)}
r={Math.max(strokeWidth * 1.25, 2)}
fill={stroke}
style={{
opacity: drawn ? 1 : 0,
transition: animate
? "opacity 200ms var(--ease-out) 450ms"
: undefined,
}}
/>
)}
{/* Hover crosshair + active point */}
{activePoint && (
<g aria-hidden pointerEvents="none">
<line
x1={activePoint.x}
y1={0}
x2={activePoint.x}
y2={height}
stroke={stroke}
strokeWidth={1}
strokeOpacity={0.25}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={activePoint.x}
cy={activePoint.y}
r={2.75}
fill="var(--color-card)"
stroke={stroke}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
</g>
)}
</svg>
{/* Cursor-following tooltip */}
{activePoint && (
<span
role="status"
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground shadow-overlay tabular-nums"
style={{
left: `${(activePoint.x / width) * 100}%`,
top: -6,
}}
>
{activeLabel ? (
<span className="mr-1.5 opacity-70">{activeLabel}</span>
) : null}
{formatValue(activePoint.v)}
</span>
)}
</span>
);
}