StreamLine
A streaming sparkline whose window scrolls smoothly left as the parent appends live data, easing each new point in from the right under an emphasised head.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/stream-line.jsonLive · canvaslive state
requests / sec · streaming
"use client";
import * as React from "react";
import {
DEFAULT_WISP,
hsl,
useCanvasScene,
useTokenColor,
useWispGain,
} from "@pouriahlabs/wisp-ui";
/**
* StreamLine — a sparkline that stays live.
*
* `Sparkline` draws a fixed series once; this is its streaming sibling. The
* parent keeps appending to `data` — a metric ticking in over a socket, a poll,
* a sensor — and the window scrolls left to keep the newest point pinned at the
* right edge under an emphasised head.
*
* The point of the component is the *ease*: a naive live chart snaps the whole
* line one notch left the instant a sample lands, which reads as a stutter at
* the exact moment you want the reader's eye drawn to the fresh value. Instead
* we track the arrival in a ref and slide the horizontal offset back to rest
* over a fraction of a second, so a new point glides in from beyond the right
* edge rather than teleporting into place.
*
* Live doesn't mean always-running: once the slide has eased to rest the frame
* is static, so `draw` reports itself settled and the loop parks instead of
* repainting an identical picture at 60fps between samples. New data can only
* arrive through the `data` prop — a render — and a render is exactly what
* wakes a settled scene, so the next sample un-parks it and glides in as usual.
* Under reduced motion `useCanvasScene` stills it to one composed frame — and
* because the ease is zeroed on the still frame, that frame is simply the
* current window at rest, never a half-slid smear.
*/
export interface StreamLineProps extends React.HTMLAttributes<HTMLDivElement> {
/** The full series so far. The parent appends to it; StreamLine shows the tail. */
data: readonly number[];
/** Labels aligned to `data`. Supplying them names the latest value for a
* screen reader; omit to leave the chart decorative (a sibling already
* names it — the common case on a stat card). */
labels?: readonly string[];
token?: string;
/** How many of the most recent points stay visible. */
window?: number;
/** Fill the area under the line. */
area?: boolean;
strokeWidth?: number;
}
/**
* Seconds the incoming point takes to ease from off the right edge to its
* resting slot. Short on purpose: long enough to kill the snap, short enough
* that at any realistic sample cadence the head is at rest — and so fully
* visible under its dot — by the time the next point lands.
*/
const SLIDE_TAU = 0.13;
export function StreamLine({
data,
labels,
token = "--wisp",
window: windowSize = 40,
area = true,
strokeWidth = 1.4,
className,
...props
}: StreamLineProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const color = useTokenColor(token, DEFAULT_WISP);
const gain = useWispGain();
// The scroll animation lives entirely in refs so an append never has to
// restart the loop or re-run an effect — the running `draw` notices the new
// length on its next frame and eases from there.
// scroll: points still to travel, in point-widths, decaying toward 0.
// prevLen: the length `draw` last saw, so it can tell how many arrived.
const scrollRef = React.useRef(0);
const prevLenRef = React.useRef<number | null>(null);
useCanvasScene(ref, {
draw: ({ ctx, width, height, dt, still }) => {
ctx.clearRect(0, 0, width, height);
const slots = Math.max(2, windowSize);
const len = data.length;
const visible = Math.min(slots, len);
const startIndex = len - visible;
// A first observation, a still frame, or a shrunk series has nothing to
// ease — adopt the length silently. Only genuine growth feeds the slide,
// and only while animating (a still frame owes a resting picture).
if (prevLenRef.current === null || still) {
prevLenRef.current = len;
} else if (len > prevLenRef.current) {
scrollRef.current += len - prevLenRef.current;
prevLenRef.current = len;
} else if (len < prevLenRef.current) {
prevLenRef.current = len;
scrollRef.current = 0;
}
if (still) {
scrollRef.current = 0;
} else {
// Exponential ease-out toward rest: framerate-independent because the
// step is derived from `dt`, and it never overshoots, so back-to-back
// arrivals just keep chasing 0 as one continuous glide.
scrollRef.current += (0 - scrollRef.current) * (1 - Math.exp(-dt / SLIDE_TAU));
if (scrollRef.current < 0.002) scrollRef.current = 0;
}
if (width < 2 || height < 2 || visible < 2) return true;
// The head dot (and its halo) needs room on every side it can touch:
// without a right-hand inset it sits exactly on `x = width` and renders
// half-clipped forever, and the vertical pad has to cover the halo
// radius for a point at the extremes of the range.
const inset = strokeWidth + 4;
// Spacing is fixed to the full window, not to how many points exist yet,
// so a half-filled stream fills from the right at the same density it
// will keep once full — no lurch in scale as the window saturates.
const step = (width - inset) / (slots - 1);
const scroll = scrollRef.current;
// Scale to the visible window: a live chart tracks the range it is
// currently showing, so points scrolling off the left stop constraining
// the axis the moment they leave.
let lo = Infinity;
let hi = -Infinity;
for (let j = 0; j < visible; j++) {
const v = data[startIndex + j]!;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
const span = hi - lo || 1;
const pad = inset;
// Right-anchored: the newest point rests just inside the right inset,
// older ones step back to the left. `scroll` shifts the whole line right
// so the just-arrived point starts beyond the edge and slides in.
const x = (j: number) => width - inset - (visible - 1 - j) * step + scroll * step;
const y = (value: number) => height - pad - ((value - lo) / span) * (height - pad * 2);
const points: Array<[number, number]> = [];
for (let j = 0; j < visible; j++) points.push([x(j), y(data[startIndex + j]!)]);
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();
// The head marks the live value. A faint halo under the dot lifts it off
// the line so the eye lands on "now" before it traces the trend — the
// whole reason to reach for a live chart over a static one.
const head = points[points.length - 1]!;
ctx.fillStyle = hsl(color, color[2] + 18, 0.22 * gain);
ctx.beginPath();
ctx.arc(head[0], head[1], strokeWidth + 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = hsl(color, color[2] + 18, 1);
ctx.beginPath();
ctx.arc(head[0], head[1], strokeWidth + 0.6, 0, Math.PI * 2);
ctx.fill();
// At rest the picture is static — park the loop until the next append's
// render wakes it. While the slide is still easing, keep running.
return scrollRef.current === 0;
},
});
// Announce only the latest value — on a stream, "where it is now" is the one
// fact worth reading aloud; the history is decorative.
const latest = data.length ? data[data.length - 1] : undefined;
const summary = labels
? `${labels[labels.length - 1] ?? "Latest"} ${latest ?? 0}`
: undefined;
return (
<div
className={className}
data-points={data.length}
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