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.json
Chart · canvascanvas
Requests by service
Week over week

SparkBaresc or double-click to exit full screendouble-tap to exit

data/spark-bar.tsx
"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