A table-cell-height SVG KPI bullet with qualitative bands, a measure bar that retargets through transforms on value change, a gliding target tick, comparative marker, and a value-vs-target tooltip.
npx shadcn@latest add @paragon/bullet-chart"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface BulletRange {
/** Upper bound of the band (ascending). The last one sets the scale max. */
to: number;
/** Qualitative name, e.g. "Poor" / "On track" / "Good". */
label?: string;
}
export interface BulletChartProps extends React.ComponentProps<"div"> {
/** The featured measure. */
value: number;
/** Target tick. */
target?: number;
/** Comparative marker, e.g. last period's value. */
comparative?: number;
/** Qualitative background bands, ascending. Defaults to thirds of `max`. */
ranges?: BulletRange[];
/** Scale maximum. Defaults to the last range, or 110% of the data. */
max?: number;
/** Total graphic height — compact enough for a table cell. */
height?: number;
/** Measure bar fill. */
color?: string;
formatValue?: (value: number) => string;
/** Accessible name for the metric, also shown in the tooltip. */
label?: string;
/** Renders the final state immediately, no first-view fill. */
static?: boolean;
}
/**
* A hand-built SVG KPI bullet at table-cell height: qualitative bands, a
* featured measure bar, a target tick, and a hollow comparative marker —
* every x position computed from one linear scale. The measure fills once on
* first view and retargets through the same transform transition whenever
* `value` changes; the target tick glides to new positions. Hovering or
* focusing opens a tooltip with the value, target delta, and the band the
* value sits in. Reduced motion renders final states instantly.
*/
export function BulletChart({
value,
target,
comparative,
ranges,
max,
height = 28,
color = "var(--color-primary)",
formatValue = (v) => v.toLocaleString("en-US"),
label,
static: isStatic = false,
className,
...props
}: BulletChartProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -24px 0px",
});
const [hovered, setHovered] = React.useState(false);
const [tipX, setTipX] = React.useState(0);
React.useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const dataMax = Math.max(
value,
target ?? 0,
comparative ?? 0,
ranges?.[ranges.length - 1]?.to ?? 0,
);
const scaleMax =
(max ?? ranges?.[ranges.length - 1]?.to ?? dataMax * 1.1) || 1;
const hasData = Number.isFinite(value) && scaleMax > 0;
const bands = React.useMemo<BulletRange[]>(() => {
if (ranges && ranges.length > 0) return ranges;
return [
{ to: scaleMax / 3, label: "Poor" },
{ to: (scaleMax * 2) / 3, label: "Fair" },
{ to: scaleMax, label: "Good" },
];
}, [ranges, scaleMax]);
const xFor = (v: number) =>
Math.max(0, Math.min(v / scaleMax, 1)) * width;
const measureH = Math.max(height * 0.34, 6);
const measureY = (height - measureH) / 2;
const tickH = Math.max(height * 0.68, measureH + 6);
const tickY = (height - tickH) / 2;
const bandAt = (v: number) =>
bands.find((b) => v <= b.to) ?? bands[bands.length - 1];
const targetDelta =
target !== undefined && target > 0 ? (value - target) / target : null;
const ariaLabel = `${label ? `${label}: ` : ""}${formatValue(value)}${
target !== undefined ? ` of ${formatValue(target)} target` : ""
}${bandAt(value)?.label ? `, ${bandAt(value)?.label}` : ""}`;
const flip = width > 0 && tipX > width * 0.6;
return (
<div
ref={containerRef}
data-slot="bullet-chart"
className={cn("relative w-full", className)}
{...props}
>
{!hasData ? (
<div
style={{ height }}
className="flex items-center justify-center rounded-md border border-dashed text-[10px] text-muted-foreground"
>
No data
</div>
) : width > 0 ? (
<svg
role="img"
aria-label={ariaLabel}
tabIndex={0}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className="block 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) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setTipX(e.clientX - rect.left);
setHovered(true);
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setHovered(false);
}}
onFocus={() => {
setTipX(xFor(value));
setHovered(true);
}}
onBlur={() => setHovered(false)}
>
{/* Qualitative bands — darkest is worst, computed edge to edge */}
{bands.map((b, i) => {
const x0 = xFor(i === 0 ? 0 : bands[i - 1].to);
const x1 = xFor(b.to);
return (
<rect
key={i}
x={x0}
y={0}
width={Math.max(x1 - x0, 0)}
height={height}
fill="currentColor"
fillOpacity={0.045 + (bands.length - 1 - i) * 0.055}
shapeRendering="crispEdges"
/>
);
})}
{/* Comparative marker — hollow, behind the measure */}
{comparative !== undefined && (
<circle
cx={0}
cy={height / 2}
r={Math.max(measureH * 0.34, 2.5)}
fill="var(--color-card)"
stroke="currentColor"
strokeOpacity={0.55}
strokeWidth={1.25}
vectorEffect="non-scaling-stroke"
aria-hidden
style={{
transform: `translateX(${xFor(comparative)}px)`,
transition: `transform calc(${
animate ? 450 : 0
}ms * var(--duration-scale, 1)) var(--ease-out)`,
}}
/>
)}
{/* Measure bar — scaleX retargets on every value change */}
<rect
x={0}
y={measureY}
width={width}
height={measureH}
rx={1.5}
fill={color}
style={{
transform: `scaleX(${drawn ? xFor(value) / Math.max(width, 1) : 0})`,
transformOrigin: "0px 0px",
transition: `transform calc(${
animate ? 500 : 0
}ms * var(--duration-scale, 1)) var(--ease-out)`,
}}
/>
{/* Target tick — glides to new targets */}
{target !== undefined && (
<rect
x={-1}
y={tickY}
width={2}
height={tickH}
fill={
drawn && value >= target
? "var(--color-success)"
: "var(--color-foreground)"
}
aria-hidden
style={{
transform: `translateX(${xFor(target)}px)`,
transition: `transform calc(${
animate ? 450 : 0
}ms * var(--duration-scale, 1)) var(--ease-out), fill 200ms var(--ease-out)`,
}}
/>
)}
</svg>
) : (
<div style={{ height }} />
)}
{/* Tooltip — anchored to the cursor, flips near the right edge */}
{hasData && width > 0 && (
<div
aria-hidden
className="pointer-events-none absolute z-10 min-w-32 rounded-lg bg-popover px-2.5 py-2 text-xs shadow-overlay transition-opacity duration-100"
style={{
opacity: hovered ? 1 : 0,
left: 0,
bottom: height + 8,
transform: `translateX(${tipX}px) translateX(${
flip ? "calc(-100% + 8px)" : "-8px"
})`,
}}
>
{label && <div className="text-muted-foreground">{label}</div>}
<div className={cn("flex flex-col gap-1", label && "mt-1.5")}>
<div className="flex items-center gap-1.5">
<span className="size-2 shrink-0 rounded-[2px]" style={{ background: color }} />
<span className="text-muted-foreground">Value</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{formatValue(value)}
</span>
</div>
{target !== undefined && (
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Target</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{formatValue(target)}
</span>
</div>
)}
{comparative !== undefined && (
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Last period</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{formatValue(comparative)}
</span>
</div>
)}
{targetDelta !== null && (
<div className="mt-0.5 flex items-center gap-1.5 border-t pt-1">
<span className="text-muted-foreground">vs target</span>
<span
className={cn(
"ml-auto pl-3 font-medium tabular-nums",
targetDelta >= 0 ? "text-success" : "text-destructive",
)}
>
{targetDelta >= 0 ? "+" : "−"}
{Math.abs(targetDelta * 100).toLocaleString("en-US", {
maximumFractionDigits: 1,
})}
%
</span>
</div>
)}
{bandAt(value)?.label && (
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Band</span>
<span className="ml-auto pl-3 font-medium">
{bandAt(value)?.label}
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}