Basic Bullet Chart — Chart.js

A bullet chart displays a single measure against qualitative ranges and a target marker, designed by Stephen Few as a space-efficient alternative to gauge charts. The linear format shows actual performance as a bar, a target as a vertical marker, and background bands representing qualitative ranges (poor/satisfactory/good). Its compact design allows multiple bullet charts to fit on a single dashboard for easy comparison across metrics.

Basic Bullet Chart rendered with Chart.js

Renders

JavaScript source (Chart.js)

// anyplot.ai
// bullet-basic: Basic Bullet Chart
// Library: chartjs 4.4.7 | JavaScript 22.23.2
// Quality: 88/100 | Created: 2026-08-24

const t = window.ANYPLOT_TOKENS;
const theme = window.ANYPLOT_THEME || "light";

// --- Data (in-memory, deterministic) ----------------------------------------
// Quarterly KPIs normalized to a common "% of target" scale so the bullets
// stay comparable at a glance, per anyplot's own dashboard-alignment note.
// Market Share deliberately lands in the "Poor" band so the demo covers all
// three qualitative outcomes, not just satisfactory/good.
const metrics = [
  { name: "Revenue Growth", actual: 92, target: 100 },
  { name: "Customer Retention", actual: 105, target: 100 },
  { name: "Market Share", actual: 45, target: 100 },
  { name: "Net Promoter Score", actual: 130, target: 100 },
];
const ranges = [60, 85, 140]; // poor / satisfactory / good band boundaries
const rangeBounds = [
  [0, ranges[0]],
  [ranges[0], ranges[1]],
  [ranges[1], ranges[2]],
];

// Grayscale bands read as chrome (structural, not data) — built from the ink
// token so they stay theme-adaptive while staying strictly grayscale.
const inkRGB = theme === "light" ? "26,26,23" : "240,239,232";
const bandColors = [`rgba(${inkRGB},0.10)`, `rgba(${inkRGB},0.18)`, `rgba(${inkRGB},0.28)`];

const categories = metrics.map((m) => m.name);
const bandThickness = 70;
const actualThickness = 26;

// --- Mount -------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// --- Custom draw: qualitative bands + target ticks + value labels -----------
// Core Chart.js plugin API (beforeDatasetsDraw / afterDatasetsDraw hooks) — no
// external plugin package. Chart.js's `grouped: false` does not actually
// overlay multiple bar datasets on the same row in this version (each dataset
// still gets its own lane), so bands, target ticks, and labels are all drawn
// manually here off the single "Actual" bar's own row geometry — guaranteeing
// every visual element for a row shares one y-center and never drifts apart.
const bulletExtrasPlugin = {
  id: "bulletExtras",
  beforeDatasetsDraw(chart) {
    const { ctx, scales } = chart;
    const meta = chart.getDatasetMeta(0);
    const half = bandThickness / 2;
    ctx.save();
    metrics.forEach((m, i) => {
      const row = meta.data[i];
      rangeBounds.forEach(([lo, hi], bandIdx) => {
        const x0 = scales.x.getPixelForValue(lo);
        const x1 = scales.x.getPixelForValue(hi);
        ctx.fillStyle = bandColors[bandIdx];
        ctx.fillRect(x0, row.y - half, x1 - x0, bandThickness);
      });
    });
    ctx.restore();
  },
  afterDatasetsDraw(chart) {
    const { ctx, scales } = chart;
    const meta = chart.getDatasetMeta(0);
    const half = (bandThickness / 2) * 1.35;
    ctx.save();
    metrics.forEach((m, i) => {
      const row = meta.data[i];
      const targetX = scales.x.getPixelForValue(m.target);
      ctx.strokeStyle = t.ink;
      ctx.lineWidth = 4;
      ctx.beginPath();
      ctx.moveTo(targetX, row.y - half);
      ctx.lineTo(targetX, row.y + half);
      ctx.stroke();

      // Shape-coded (not hue-coded) over/under-target cue — keeps the CVD-safe
      // all-ink text while still calling out which metrics missed target.
      const arrow = m.actual >= m.target ? "▲" : "▼";
      ctx.fillStyle = t.ink;
      ctx.font = "600 16px sans-serif";
      ctx.textBaseline = "middle";
      ctx.textAlign = "left";
      ctx.fillText(`${arrow} ${m.actual}%`, row.x + 10, row.y);
    });
    ctx.restore();
  },
};

// --- Chart ---------------------------------------------------------------------
new Chart(canvas, {
  type: "bar",
  data: {
    labels: categories,
    datasets: [
      {
        label: "Actual",
        data: metrics.map((m) => [0, m.actual]),
        backgroundColor: t.palette[0],
        barThickness: actualThickness,
        borderWidth: 0,
      },
    ],
  },
  options: {
    indexAxis: "y",
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: { right: 60 } },
    plugins: {
      title: {
        display: true,
        text: "Quarterly KPI Dashboard · bullet-basic · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 22 },
      },
      legend: {
        position: "bottom",
        labels: {
          color: t.ink,
          font: { size: 16 },
          generateLabels: () => [
            { text: "Poor", fillStyle: bandColors[0], strokeStyle: bandColors[0], lineWidth: 0 },
            { text: "Satisfactory", fillStyle: bandColors[1], strokeStyle: bandColors[1], lineWidth: 0 },
            { text: "Good", fillStyle: bandColors[2], strokeStyle: bandColors[2], lineWidth: 0 },
            { text: "Actual", fillStyle: t.palette[0], strokeStyle: t.palette[0], lineWidth: 0 },
            { text: "Target", fillStyle: t.ink, strokeStyle: t.ink, lineWidth: 0 },
          ],
        },
      },
    },
    scales: {
      x: {
        min: 0,
        max: 150,
        ticks: { color: t.inkSoft, font: { size: 14 }, callback: (v) => `${v}%` },
        grid: { color: t.grid },
        title: { display: true, text: "Percent of Quarterly Target", color: t.ink, font: { size: 16 } },
      },
      y: {
        ticks: { color: t.inkSoft, font: { size: 14 } },
        grid: { display: false },
      },
    },
  },
  plugins: [bulletExtrasPlugin],
});

Part of Basic Bullet Chart on anyplot.ai.

Other implementations