Skip to contentVibraUI
Charts

Bullet chart

One measure against its target and its qualitative bands, in the width of a table row.

Server-compatible: no hooks, no client boundary, no recharts. Stephen Few's bullet graph: three shades of one neutral for the bands, a near-ink bar for the reading, and a single saturated tick for the target — so "did it clear the target" is answerable without reading a number. The scale starts at zero and runs to whichever of the ranges, the value, and the target is largest; ranges are sorted before they are drawn. It reads as a meter, with the whole reading as its aria-valuetext, and stacks well in a column of measures that share a meaning ("against plan") rather than a scale. One measure at a time: several measures on one scale are a bar chart, and a single number with no target and no bands is a stat tile, which says it in less space.

Install

npx shadcn@latest add @vibra/bullet-chart

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
labelReact.ReactNode—What the number measures. Plain text also names the meter.
valuenumber—The reading, drawn as the near-ink bar.
targetnumber—The number to beat, drawn as a tick across the track.
rangesnumber[]—Ascending band boundaries — the qualitative ranges the value is read against.
maxnumberthe largest of ranges, value, and targetThe end of the scale. The start is always zero.
valueFormatter(n: number) => stringwhole number with separatorsFormats the value, the target, and the spoken reading.
size"sm" | "default""default"sm thins the track and drops the text to text-xs, for a dense list.

Dependencies

Source

components/ui/bullet-chart.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import { clamp, formatNumber } from "@/lib/format"

// Three shades of the one neutral, darkest at the low end: the bands are
// background, so they step back as the reading improves and never compete with
// the value bar for attention.
const BAND_FILL = ["bg-muted", "bg-muted/65", "bg-muted/35"]

const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

export type BulletChartProps = React.ComponentProps<"div"> & {
  label: React.ReactNode
  value: number
  /** The number to beat, drawn as a tick across the track. */
  target: number
  /** Ascending band boundaries — the qualitative ranges the value is read against. */
  ranges: number[]
  /** The end of the scale. Defaults to whichever of the ranges, value, and target is largest. */
  max?: number
  valueFormatter?: (n: number) => string
  size?: "sm" | "default"
}

function BulletChart({
  className,
  label,
  value,
  target,
  ranges,
  max,
  valueFormatter = DEFAULT_FORMAT,
  size = "default",
  "aria-label": ariaLabel,
  ...props
}: BulletChartProps) {
  const bounds = Math.max(max ?? 0, ...ranges, value, target)
  // A scale of zero width has no positions on it, so every mark sits at the
  // start rather than dividing by nothing.
  const share = (n: number) => (bounds > 0 ? clamp((n / bounds) * 100, 0, 100) : 0)

  const sorted = [...ranges].sort((a, b) => a - b)
  const bands = sorted.map((edge, i) => ({
    edge,
    width: share(edge) - share(sorted[i - 1] ?? 0),
    className: BAND_FILL[Math.min(i, BAND_FILL.length - 1)],
  }))

  const reading = `${valueFormatter(value)} against a target of ${valueFormatter(target)}`

  return (
    <div
      data-slot="bullet-chart"
      data-size={size}
      className={cn("flex w-full flex-col gap-1.5", size === "sm" && "gap-1", className)}
      {...props}
    >
      <div className="flex items-baseline justify-between gap-3">
        <span
          data-slot="bullet-chart-label"
          className={cn("truncate text-muted-foreground", size === "sm" ? "text-xs" : "text-sm")}
        >
          {label}
        </span>
        {/* Two flex items with a gap, not a padded inline span: in a
            right-to-left page, Latin figures on both sides join into one
            left-to-right run, and a logical padding then lands on the run's
            outer edge — "$50,913target $68,000". */}
        <span
          data-slot="bullet-chart-value"
          className={cn("flex shrink-0 items-baseline gap-2 tabular-nums", size === "sm" ? "text-xs" : "text-sm")}
        >
          <span className="font-medium">{valueFormatter(value)}</span>
          <span className="text-muted-foreground">{`target ${valueFormatter(target)}`}</span>
        </span>
      </div>

      <div
        data-slot="bullet-chart-track"
        role="meter"
        // A ReactNode label cannot become a string, so the reading stands in
        // rather than leaving the meter unnamed; pass aria-label for the rest.
        aria-label={ariaLabel ?? (typeof label === "string" ? label : reading)}
        aria-valuenow={clamp(value, 0, bounds)}
        aria-valuemin={0}
        aria-valuemax={bounds}
        aria-valuetext={reading}
        className={cn(
          "relative w-full overflow-hidden rounded-[2px] bg-muted/25",
          size === "sm" ? "h-4" : "h-5"
        )}
      >
        <div aria-hidden="true" className="absolute inset-0 flex">
          {bands.map((band, i) => (
            <div
              key={`${band.edge}-${i}`}
              data-slot="bullet-chart-band"
              className={band.className}
              style={{ width: `${band.width}%` }}
            />
          ))}
        </div>

        <div
          data-slot="bullet-chart-value-bar"
          aria-hidden="true"
          className="absolute top-1/2 start-0 h-1/2 -translate-y-1/2 rounded-e-[2px] bg-foreground"
          style={{ width: `${share(value)}%` }}
        />

        {/* The tick is the only saturated mark on the track, so "did it clear
            the target" is answerable without reading a number. */}
        <div
          data-slot="bullet-chart-target"
          aria-hidden="true"
          // On the inline axis, so a right-to-left track reads its target from
          // the right, and centred on it from either side.
          className="absolute top-0 h-full w-0.5 -translate-x-1/2 bg-danger rtl:translate-x-1/2"
          style={{ insetInlineStart: `${share(target)}%` }}
        />
      </div>
    </div>
  )
}

export { BulletChart }