ChartReveal
A line that draws on, then a crosshair whose tooltip measures itself against the container so it can never run off an edge.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/chart-reveal.jsonChart · canvashover
- Jan $12.4k
- Feb $19.1k
- Mar $15.3k
- Apr $25.8k
- May $22.0k
- Jun $31.4k
- Jul $28.2k
- Aug $35.9k
- Sep $33.1k
- Oct $41.6k
- Nov $44.0k
- Dec $48.2k
"use client";
import * as React from "react";
import {
DEFAULT_LANTERN,
DEFAULT_WISP,
hsl,
prefersStill,
useCanvasScene,
useRevealClock,
useTokenColor,
useWispGain,
} from "@pouriahlabs/wisp-ui";
/**
* ChartReveal — a line chart that draws on, with a crosshair that stays in
* bounds.
*
* The reveal is the easy half. The crosshair is where the library's thesis
* shows up in a chart: the tooltip measures its own text against the container
* and clamps, so at the first and last datapoint it slides inward instead of
* running off the edge. Charting libraries usually solve this with a portal and
* a popper; on a canvas it is four lines of arithmetic and it can never escape.
*
* When the pointer is away, the crosshair sweeps on its own — so the component
* still demonstrates itself in a screenshot or a scroll-past.
*
* The canvas is decoration. Every point's label and value also render as a
* real, always-in-DOM list — visually clipped, not `display: none`, the same
* treatment `ArcReveal`'s legend gets — so the chart survives a screen reader
* and a failed paint instead of being a bare canvas with nothing to read.
*/
export interface ChartRevealPoint {
label: string;
value: number;
}
export interface ChartRevealProps extends React.HTMLAttributes<HTMLDivElement> {
data: readonly ChartRevealPoint[];
lineToken?: string;
crosshairToken?: string;
/** Draw-on duration, ms. Ignored when `progress` is supplied. */
duration?: number;
/** Externally driven reveal, `0`–`1`. Omit to self-animate. */
progress?: number;
/** Format the tooltip value. */
format?: (value: number) => string;
/** Show every nth label on the axis. */
labelEvery?: number;
/** Sweep the crosshair when the pointer is elsewhere. */
autoSweep?: boolean;
}
const PAD = { left: 14, right: 14, top: 18, bottom: 26 };
export function ChartReveal({
data,
lineToken = "--wisp",
crosshairToken = "--lantern",
duration = 1500,
progress,
format = (v) => v.toFixed(1),
labelEvery = 3,
autoSweep = true,
className,
...props
}: ChartRevealProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const line = useTokenColor(lineToken, DEFAULT_WISP);
const cross = useTokenColor(crosshairToken, DEFAULT_LANTERN);
const grid = useTokenColor("--wisp-line", [198, 13, 24]);
const ink = useTokenColor("--wisp-ink-faint", [198, 9, 44]);
const surface = useTokenColor("--wisp-surface", [200, 20, 8]);
const gain = useWispGain();
const light = gain < 0.9;
// The public prop is ms; the canvas ticks the reveal clock in seconds.
const { tick, reset } = useRevealClock({ duration: duration / 1000, progress });
const sweep = React.useRef(0);
const pointer = React.useRef<number | null>(null);
// Once stilled, useCanvasScene's rAF loop is stopped, so nothing re-reads
// `pointer` on its own. A render nudges its still-frame refresh effect,
// which repaints with whatever `pointer` holds now.
const [, repaintStill] = React.useState(0);
useCanvasScene(ref, {
setup: () => {
reset();
sweep.current = 0;
},
draw: ({ ctx, width, height, dt, still }) => {
ctx.clearRect(0, 0, width, height);
if (width < 40 || height < 40 || data.length < 2) return;
sweep.current += dt * 0.9;
const t = tick(dt, still);
const values = data.map((d) => d.value);
const lo = Math.min(...values);
const hi = Math.max(...values);
// A little headroom so the peak never touches the top edge.
const span = (hi - lo) * 1.15 || 1;
const floor = lo - (hi - lo) * 0.075;
const x = (i: number) => PAD.left + (i / (data.length - 1)) * (width - PAD.left - PAD.right);
const y = (v: number) => PAD.top + (1 - (v - floor) / span) * (height - PAD.top - PAD.bottom);
const base = height - PAD.bottom;
// Baseline grid.
ctx.strokeStyle = hsl(grid, grid[2], 0.55);
ctx.lineWidth = 1;
for (let i = 0; i < 3; i++) {
const gy = Math.round(PAD.top + (i / 2) * (height - PAD.top - PAD.bottom)) + 0.5;
ctx.beginPath();
ctx.moveTo(PAD.left, gy);
ctx.lineTo(width - PAD.right, gy);
ctx.stroke();
}
// Axis labels.
ctx.fillStyle = hsl(ink, ink[2], 0.9);
ctx.font = "9px ui-monospace, SFMono-Regular, Menlo, monospace";
ctx.textAlign = "center";
for (let i = 0; i < data.length; i += labelEvery) {
ctx.fillText(data[i]!.label, x(i), height - 9);
}
ctx.textAlign = "start";
// The revealed span.
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(values[i]!)]);
if (frac > 0 && lastIndex < data.length - 1) {
const a = values[lastIndex]!;
const b = values[lastIndex + 1]!;
points.push([
x(lastIndex) + (x(lastIndex + 1) - x(lastIndex)) * frac,
y(a) + (y(b) - y(a)) * frac,
]);
}
if (points.length < 2) return;
const fill = ctx.createLinearGradient(0, PAD.top, 0, base);
fill.addColorStop(0, hsl(line, line[2], 0.3 * gain));
fill.addColorStop(1, hsl(line, line[2], 0));
ctx.fillStyle = fill;
ctx.beginPath();
ctx.moveTo(points[0]![0], base);
for (const [px, py] of points) ctx.lineTo(px, py);
ctx.lineTo(points[points.length - 1]![0], base);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = hsl(line, line[2] + 6, 1);
ctx.lineWidth = 1.8;
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(line, line[2] + 22, 1);
ctx.beginPath();
ctx.arc(end[0], end[1], 3, 0, Math.PI * 2);
ctx.fill();
// Still drawing on: a driven reveal parks and repaints on the parent's
// render, a self-animating one keeps looping until the line completes.
if (t < 1) return progress !== undefined;
// Crosshair: the pointer if it's here, otherwise a self-demo sweep.
let index: number;
if (pointer.current !== null) {
index = Math.round(
((pointer.current - PAD.left) / (width - PAD.left - PAD.right)) * (data.length - 1),
);
} else if (autoSweep) {
const pingPong = Math.abs(((sweep.current * 0.5) % 2) - 1);
index = Math.round(pingPong * (data.length - 1));
} else {
// Reveal done, no pointer, no self-sweep: nothing moves — settle.
return true;
}
index = Math.max(0, Math.min(data.length - 1, index));
const hx = x(index);
const hy = y(values[index]!);
ctx.strokeStyle = hsl(cross, cross[2], 0.5);
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(hx, PAD.top - 4);
ctx.lineTo(hx, base);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = hsl(cross, cross[2] + 20, 1);
ctx.beginPath();
ctx.arc(hx, hy, 3.4, 0, Math.PI * 2);
ctx.fill();
// The tooltip, measured and clamped so it cannot leave the container.
const text = `${data[index]!.label} ${format(values[index]!)}`;
ctx.font = "600 10px ui-monospace, SFMono-Regular, Menlo, monospace";
const boxWidth = ctx.measureText(text).width + 16;
const boxHeight = 20;
const bx = Math.max(2, Math.min(width - boxWidth - 2, hx - boxWidth / 2));
const by = Math.max(2, hy - boxHeight - 10);
ctx.fillStyle = hsl(surface, light ? 100 : surface[2], 0.95);
ctx.strokeStyle = hsl(cross, cross[2], 0.55);
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(bx, by, boxWidth, boxHeight, 3);
else ctx.rect(bx, by, boxWidth, boxHeight);
ctx.fill();
ctx.stroke();
ctx.fillStyle = hsl(cross, light ? Math.max(cross[2] - 6, 22) : cross[2] + 26, 1);
ctx.textBaseline = "middle";
ctx.fillText(text, bx + 8, by + boxHeight / 2 + 0.5);
ctx.textBaseline = "alphabetic";
// Reached here only with a live pointer or the self-sweep running, both of
// which need the next frame — never settled.
return false;
},
});
return (
<div className={className} {...props}>
<canvas
ref={ref}
aria-hidden
className="block h-full w-full"
onPointerMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const wasAway = pointer.current === null;
pointer.current = event.clientX - rect.left;
// One render on entry wakes a settled loop (see useCanvasScene); once
// it is running it tracks the pointer itself without further renders.
// Under reduced motion there is no loop, so every move must repaint.
if (prefersStill() || wasAway) repaintStill((n) => n + 1);
}}
onPointerLeave={() => {
pointer.current = null;
repaintStill((n) => n + 1);
}}
/>
<ul className="wisp-chart-summary">
{data.map((point) => (
<li key={point.label}>
{point.label} {format(point.value)}
</li>
))}
</ul>
</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