Sparkline
A trend line that draws on with an emphasised endpoint, and accepts external progress so a parent can drive several off one clock.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/sparkline.json"use client";
import * as React from "react";
import {
DEFAULT_WISP,
hsl,
useCanvasScene,
useRevealClock,
useTokenColor,
useWispGain,
} from "@pouriahlabs/wisp-ui";
/**
* Sparkline — a trend line that draws on, with an emphasised endpoint.
*
* Every stat tile wants one. The endpoint dot is not decoration: on a line this
* small the reader's question is "where did it end up", and marking the last
* point answers it before they trace the shape.
*
* Exposed as a standalone so `StatTile` can compose it, but it takes a
* `progress` prop so a parent can drive several of these off one clock — three
* animations racing to finish at different times is what makes a stat card feel
* cheap.
*/
export interface SparklineProps extends React.HTMLAttributes<HTMLDivElement> {
data: readonly number[];
/** Point labels, in order. Enables an accessible summary — omit to leave
* the chart decorative (the common case when a sibling already names it). */
labels?: readonly string[];
token?: string;
/** Draw-on duration, ms. Ignored when `progress` is supplied. */
duration?: number;
/** Externally driven reveal, `0`–`1`. Omit to self-animate. */
progress?: number;
/**
* Pause before replaying, ms. `0` (the default) draws once and holds — a
* product sparkline shouldn't reset every few seconds. Set it for a demo.
*/
loop?: number;
/** Fill the area under the line. */
area?: boolean;
strokeWidth?: number;
}
export function Sparkline({
data,
labels,
token = "--wisp",
duration = 1300,
progress,
loop = 0,
area = true,
strokeWidth = 1.4,
className,
...props
}: SparklineProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const color = useTokenColor(token, DEFAULT_WISP);
const gain = useWispGain();
// The public props are ms; the canvas ticks the reveal clock in seconds.
const { tick, reset } = useRevealClock({ duration: duration / 1000, loop: loop / 1000, progress });
const seriesKey = data.join(",");
useCanvasScene(ref, {
setup: () => reset(),
draw: ({ ctx, width, height, dt, still }) => {
ctx.clearRect(0, 0, width, height);
if (width < 2 || height < 2 || data.length < 2) return;
const t = tick(dt, still);
// A driven reveal repaints on the parent's next render; a self-animating
// one holds once it finishes and isn't looping. Either way the loop can
// stop rather than repaint an identical frame at 60fps.
const done = progress !== undefined || (loop === 0 && t >= 1);
const lo = Math.min(...data);
const hi = Math.max(...data);
const span = hi - lo || 1;
const pad = 3;
const x = (i: number) => (i / (data.length - 1)) * (width - 2) + 1;
const y = (value: number) => height - pad - ((value - lo) / span) * (height - pad * 2);
// Interpolate the head so the line grows smoothly between samples rather
// than snapping from point to point.
const upto = t * (data.length - 1);
const lastIndex = Math.min(data.length - 1, Math.floor(upto));
const frac = upto - lastIndex;
const points: Array<[number, number]> = [];
for (let i = 0; i <= lastIndex; i++) points.push([x(i), y(data[i]!)]);
if (frac > 0 && lastIndex < data.length - 1) {
const a = data[lastIndex]!;
const b = data[lastIndex + 1]!;
points.push([
x(lastIndex) + (x(lastIndex + 1) - x(lastIndex)) * frac,
y(a) + (y(b) - y(a)) * frac,
]);
}
if (points.length < 2) return;
if (area) {
const fill = ctx.createLinearGradient(0, 0, 0, height);
fill.addColorStop(0, hsl(color, color[2], 0.28 * gain));
fill.addColorStop(1, hsl(color, color[2], 0));
ctx.fillStyle = fill;
ctx.beginPath();
ctx.moveTo(points[0]![0], height);
for (const [px, py] of points) ctx.lineTo(px, py);
ctx.lineTo(points[points.length - 1]![0], height);
ctx.closePath();
ctx.fill();
}
ctx.strokeStyle = hsl(color, color[2], 0.95);
ctx.lineWidth = strokeWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(points[0]![0], points[0]![1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i]![0], points[i]![1]);
ctx.stroke();
const end = points[points.length - 1]!;
ctx.fillStyle = hsl(color, color[2] + 18, 1);
ctx.beginPath();
ctx.arc(end[0], end[1], strokeWidth + 0.6, 0, Math.PI * 2);
ctx.fill();
return done;
},
});
const summary = labels
? labels.map((label, i) => `${label} ${data[i] ?? 0}`).join(", ")
: undefined;
return (
<div
className={className}
data-series={seriesKey}
role={summary ? "img" : undefined}
aria-label={summary}
aria-hidden={summary ? undefined : true}
{...props}
>
<canvas ref={ref} className="block h-full w-full" />
</div>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React