SparkBar
The categorical sibling of Sparkline — not every metric is temporal. Bars land in sequence from a real zero line, so a negative is never drawn as a short positive.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/spark-bar.jsonChart · canvascanvas
Requests by service
Week over week
"use client";
import * as React from "react";
import {
DEFAULT_WISP,
easeOutCubic,
hsl,
useCanvasScene,
useRevealClock,
useTokenColor,
useWispGain,
} from "@pouriahlabs/wisp-ui";
/**
* SparkBar — the categorical sibling of `Sparkline`.
*
* Not every metric is temporal. Spend by channel, errors by service, signups by
* plan: a line through those implies a continuity between the categories that
* doesn't exist, and readers trace slopes that mean nothing. Bars say the
* categories are separate, which is the truth about the data.
*
* Three things it does that a `<div>` bar chart can't easily.
*
* **Zero is a real baseline.** Deltas go negative, and a bar chart that plots
* `-4` as a short upward bar is a lie. When the series contains a negative the
* zero line is anchored and bars grow downward from it.
*
* **The bars land in sequence, not together.** Each starts slightly after the
* one before it, so the eye is walked left to right across the categories
* rather than shown the whole chart appearing at once.
*
* **The tallest bar is emphasised.** On a chart this small the reader's
* question is "which one is winning", and dimming the rest answers it before
* they compare heights.
*
* Like `Sparkline` it accepts external `progress`, so a parent can drive
* several of these plus a ticker off one clock — animations racing to finish at
* different times is what makes a stat card feel cheap.
*/
export interface SparkBarProps extends React.HTMLAttributes<HTMLDivElement> {
data: readonly number[];
/** Category names. Used for the accessible summary, not drawn. */
labels?: readonly string[];
token?: string;
/** Grow-in 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 chart shouldn't reset every few seconds. Set it for a demo.
*/
loop?: number;
/** Space between bars, CSS pixels. */
gap?: number;
/**
* Which bar to emphasise. `"max"` picks the tallest, a number picks an index,
* `false` treats them all alike.
*/
highlight?: "max" | number | false;
}
export function SparkBar({
data,
labels,
token = "--wisp",
duration = 1300,
progress,
loop = 0,
gap = 3,
highlight = "max",
className,
...props
}: SparkBarProps) {
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 < 1) return;
const t = tick(dt, still);
// Driven reveals repaint on the parent's render; a self-animating one
// holds once finished and not looping — either way the loop can stop.
const done = progress !== undefined || (loop === 0 && t >= 1);
// A series that never goes below zero gets the full height; one that does
// gets a real zero line, because a negative drawn as a short positive bar
// is worse than no chart.
const lo = Math.min(0, ...data);
const hi = Math.max(0, ...data);
const span = hi - lo || 1;
const pad = 2;
const plot = height - pad * 2;
const y = (value: number) => height - pad - ((value - lo) / span) * plot;
const zero = y(0);
const slot = width / data.length;
const barWidth = Math.max(1, slot - gap);
const peak = data.reduce(
(best, value, i) => (Math.abs(value) > Math.abs(data[best]!) ? i : best),
0,
);
const emphasis = highlight === "max" ? peak : highlight;
// Bars land in sequence. The stagger is capped at 55% of the timeline so
// the last bar still has room to ease rather than snapping into place.
const stagger = data.length > 1 ? 0.55 / (data.length - 1) : 0;
for (let i = 0; i < data.length; i++) {
const value = data[i]!;
const local = clamp01((t - i * stagger) / Math.max(0.001, 1 - 0.55));
if (local <= 0) continue;
const full = y(value);
const grown = zero + (full - zero) * easeOutCubic(local);
const top = Math.min(zero, grown);
const tall = Math.max(1, Math.abs(grown - zero));
const lit = emphasis === i;
ctx.fillStyle = hsl(color, color[2] + (lit ? 10 : 0), (lit ? 0.95 : 0.36) * gain);
ctx.fillRect(i * slot + gap / 2, top, barWidth, tall);
}
// The zero line only exists when it is inside the plot; drawing it flush
// against the floor of an all-positive series is just a border.
if (lo < 0) {
ctx.fillStyle = hsl(color, color[2], 0.3 * gain);
ctx.fillRect(0, zero, width, 1);
}
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>
);
}
function clamp01(t: number): number {
return t < 0 ? 0 : t > 1 ? 1 : t;
}
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