Sparkbar Cell
Charts

Sparkbar Cell

A 20px micro bar chart for table cells with win/loss and magnitude modes, exact band math, per-bar hover and keyboard tooltips, and a baseline rise-in on first view.

Install

npx shadcn@latest add @paragon/sparkbar-cell

sparkbar-cell.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface SparkbarCellProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** Series values, oldest first. Signed values allowed in both modes. */
  data: number[];
  /** Per-bar labels shown in the tooltip. */
  labels?: string[];
  /**
   * `winloss` diverges fixed-height bars around a center baseline (sign
   * only); `magnitude` scales bar heights from a computed zero line.
   */
  mode?: "winloss" | "magnitude";
  width?: number;
  height?: number;
  /** Positive / main bar color. */
  color?: string;
  /** Negative bar color. */
  negativeColor?: string;
  /** Gap between bars, px. */
  gap?: number;
  formatValue?: (value: number) => string;
  /** Renders the final state immediately, no rise-in. */
  static?: boolean;
}

/**
 * A 20px-tall micro bar chart built for table cells. `winloss` mode renders
 * Tufte-style fixed bars diverging around a center baseline; `magnitude`
 * scales every bar from a computed zero line — both from exact band math.
 * Bars rise from their baseline once on first view. Hovering, tapping, or
 * arrowing through bars (the cell is one keyboard stop) highlights a bar and
 * opens a value tooltip that re-anchors near either edge. Zero values render
 * a neutral notch so periods are never silently missing. Reduced motion
 * renders the final state.
 */
export function SparkbarCell({
  data,
  labels,
  mode = "winloss",
  width = 96,
  height = 20,
  color = "var(--color-success)",
  negativeColor = "var(--color-destructive)",
  gap = 2,
  formatValue = (v) => v.toLocaleString("en-US"),
  static: isStatic = false,
  className,
  ...props
}: SparkbarCellProps) {
  const ref = React.useRef<SVGSVGElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const [active, setActive] = React.useState<number | null>(null);

  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const n = data.length;
  const hasData = n > 0;

  // --- Band scale (every coordinate computed) ------------------------------
  const barW = n > 0 ? Math.max((width - (n - 1) * gap) / n, 1) : 0;
  const xFor = (i: number) => i * (barW + gap);

  const min = hasData ? Math.min(...data, 0) : 0;
  const max = hasData ? Math.max(...data, 0) : 1;
  const range = max - min || 1;

  // Zero line: center for winloss; proportional for magnitude.
  const zeroY =
    mode === "winloss" ? height / 2 : height - ((0 - min) / range) * height;

  const barFor = (v: number) => {
    if (mode === "winloss") {
      const h = height / 2 - 1;
      if (v > 0) return { y: zeroY - 1 - h, h };
      if (v < 0) return { y: zeroY + 1, h };
      return { y: zeroY - 0.75, h: 1.5 }; // neutral notch
    }
    const h = Math.max((Math.abs(v) / range) * height, v === 0 ? 1.5 : 2);
    return v >= 0 ? { y: zeroY - h, h } : { y: zeroY, h };
  };

  const fillFor = (v: number) =>
    v > 0 ? color : v < 0 ? negativeColor : "currentColor";

  const pickNearest = React.useCallback(
    (clientX: number) => {
      const svg = ref.current;
      if (!svg || n === 0) return;
      const rect = svg.getBoundingClientRect();
      const localX = ((clientX - rect.left) / rect.width) * width;
      const i = Math.min(
        n - 1,
        Math.max(0, Math.floor(localX / (barW + gap))),
      );
      setActive(i);
    },
    [n, width, barW, gap],
  );

  const wins = data.filter((v) => v > 0).length;
  const losses = data.filter((v) => v < 0).length;
  const activeValue = active !== null ? data[active] : null;
  const activeLabel =
    active !== null && labels ? labels[Math.min(active, labels.length - 1)] : null;

  if (!hasData) {
    return (
      <span
        role="img"
        aria-label="No data"
        className={cn(
          "inline-flex shrink-0 items-center justify-center rounded-md bg-current/[0.04] text-[10px] text-muted-foreground",
          className,
        )}
        style={{ width, height }}
        {...props}
      >

      </span>
    );
  }

  return (
    <span
      className={cn("relative inline-flex shrink-0 align-middle", className)}
      style={{ width, height }}
      {...props}
    >
      <svg
        ref={ref}
        role="img"
        tabIndex={0}
        aria-label={
          mode === "winloss"
            ? `${wins} up, ${losses} down of ${n} periods`
            : `Bar spark of ${n} values, latest ${formatValue(data[n - 1] ?? 0)}`
        }
        viewBox={`0 0 ${width} ${height}`}
        width={width}
        height={height}
        data-slot="sparkbar-cell"
        className="touch-none overflow-visible rounded-[3px] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
        onPointerMove={(e) => pickNearest(e.clientX)}
        onPointerDown={(e) => pickNearest(e.clientX)}
        onPointerLeave={(e) => {
          if (e.pointerType === "touch") return;
          setActive(null);
        }}
        onFocus={() => setActive(n - 1)}
        onBlur={() => setActive(null)}
        onKeyDown={(e) => {
          if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
            e.preventDefault();
            setActive((prev) => {
              const cur = prev ?? n - 1;
              const next = e.key === "ArrowRight" ? cur + 1 : cur - 1;
              return Math.max(0, Math.min(n - 1, next));
            });
          } else if (e.key === "Home" || e.key === "End") {
            e.preventDefault();
            setActive(e.key === "Home" ? 0 : n - 1);
          }
        }}
      >
        {/* Baseline hairline */}
        <line
          x1={0}
          x2={width}
          y1={zeroY}
          y2={zeroY}
          stroke="currentColor"
          strokeOpacity={0.18}
          vectorEffect="non-scaling-stroke"
          shapeRendering="crispEdges"
          aria-hidden
        />
        {data.map((v, i) => {
          const { y, h } = barFor(v);
          const dimmed = active !== null && active !== i;
          return (
            <rect
              key={i}
              x={xFor(i)}
              y={y}
              width={barW}
              height={h}
              rx={Math.min(1, barW / 2)}
              fill={fillFor(v)}
              fillOpacity={v === 0 ? 0.35 : dimmed ? 0.35 : active === i ? 1 : 0.8}
              style={{
                transform: drawn ? "scaleY(1)" : "scaleY(0)",
                transformOrigin: `0px ${zeroY}px`,
                transition: `transform calc(${
                  animate ? 260 : 0
                }ms * var(--duration-scale, 1)) var(--ease-out) calc(${Math.min(
                  i * 16,
                  320,
                )}ms * var(--duration-scale, 1)), fill-opacity 120ms var(--ease-out)`,
              }}
            />
          );
        })}
      </svg>

      {/* Per-bar tooltip — re-anchors near either edge */}
      {active !== null && activeValue !== null && (
        <span
          role="status"
          className="pointer-events-none absolute z-10 rounded-md bg-primary px-2 py-1 text-[11px] font-medium whitespace-nowrap text-primary-foreground shadow-overlay tabular-nums"
          style={{
            left: `${((xFor(active) + barW / 2) / width) * 100}%`,
            top: -6,
            transform: `translate(${
              xFor(active) < width * 0.33
                ? "-8px"
                : xFor(active) > width * 0.67
                  ? "calc(-100% + 8px)"
                  : "-50%"
            }, -100%)`,
          }}
        >
          {activeLabel ? (
            <span className="mr-1.5 opacity-70">{activeLabel}</span>
          ) : null}
          {mode === "winloss" && activeValue !== 0 ? (
            <span className="mr-1 opacity-70">
              {activeValue > 0 ? "▲" : "▼"}
            </span>
          ) : null}
          {formatValue(activeValue)}
        </span>
      )}
    </span>
  );
}