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.
npx shadcn@latest add @paragon/sparkbar-cell"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>
);
}