Bullet Chart
Charts

Bullet Chart

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.

Install

npx shadcn@latest add @paragon/bullet-chart

bullet-chart.tsx

"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>
  );
}