Comparison / Intermediate

Bullet Chart

Compare actual values with targets and reference ranges.

Back to collection

Interactive Chart Preview

JAVASCRIPT
Live, interactive exampleChart.js

Chart.js Code Example

JAVASCRIPT
import Chart from 'chart.js/auto';

// Illustrative data used by the live preview.
const chartData = {
  "categoryLabel": "Team",
  "labels": [
    "Setup",
    "Billing",
    "Access"
  ],
  "datasets": [
    {
      "label": "Actual (Tasks)",
      "data": [
        84,
        67,
        92
      ],
      "backgroundColor": "#3b82f6"
    },
    {
      "label": "Target (Tasks)",
      "data": [
        90,
        75,
        85
      ],
      "backgroundColor": "#0f172a"
    },
    {
      "label": "Range 1 End",
      "data": [
        60,
        50,
        60
      ],
      "backgroundColor": "#cbd5e1"
    },
    {
      "label": "Range 2 End",
      "data": [
        80,
        70,
        80
      ],
      "backgroundColor": "#e2e8f0"
    },
    {
      "label": "Range 3 End",
      "data": [
        100,
        100,
        100
      ],
      "backgroundColor": "#f1f5f9"
    }
  ]
};

function createConfig(chartData) {
    const labels = chartData.labels;
    const names = chartData.datasets.map(series => series.label);
    const values = chartData.datasets.map(series => series.data);
    const maximum = Math.max(...values.flat()) * 1.05;
    return {
        type: 'bar',
        data: { labels, datasets: [
                ...[4, 3, 2].map((column, index) => ({ label: names[column], data: values[column], backgroundColor: ['#f1f5f9', '#e2e8f0', '#cbd5e1'][index], grouped: false, order: 3 - index, categoryPercentage: 0.8, barPercentage: 1, maxBarThickness: 30 })),
                { label: names[0], data: values[0], backgroundColor: '#3b82f6', grouped: false, order: 0, categoryPercentage: 0.8, barPercentage: 0.35, maxBarThickness: 10 },
            ] },
        plugins: [{ id: 'target-marks', afterDatasetsDraw: chart => {
                    const ctx = chart.ctx;
                    const half = Math.min(12, (chart.chartArea.bottom - chart.chartArea.top) / labels.length * 0.3);
                    ctx.save();
                    ctx.strokeStyle = '#0f172a';
                    ctx.lineWidth = 2;
                    labels.forEach((_, row) => {
                        const x = chart.scales.x.getPixelForValue(values[1][row]), y = chart.scales.y.getPixelForValue(row);
                        ctx.beginPath();
                        ctx.moveTo(x, y - half);
                        ctx.lineTo(x, y + half);
                        ctx.stroke();
                    });
                    ctx.restore();
                } }],
        options: {
            indexAxis: 'y', responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false },
            plugins: {
                legend: { position: 'bottom', onClick: () => { }, labels: { usePointStyle: true, generateLabels: () => [
                            { text: names[0], fillStyle: '#3b82f6', strokeStyle: '#3b82f6', pointStyle: 'rect' },
                            { text: names[1], fillStyle: '#0f172a', strokeStyle: '#0f172a', pointStyle: 'line', rotation: 90 },
                        ] } },
                tooltip: { filter: item => item.datasetIndex === 3, callbacks: { label: item => names.map((name, column) => `${name}: ${values[column][item.dataIndex]}`) } },
            },
            scales: {
                x: { min: 0, max: maximum, title: { display: true, text: 'Value (Same Units)' } },
                y: { grid: { display: false }, title: { display: true, text: chartData.categoryLabel } },
            },
        },
    };
}

const chart = new Chart(document.getElementById('myChart'), createConfig(chartData));

Preview and code follow the same library selection.

Run This Example

Install the package below in a browser project such as Vite. Put the copied code in a client JavaScript module and add this container to the page before the module runs.

npm install chart.js@4.5.1
<div style="height:320px"><canvas id="myChart"></canvas></div>

The example includes its sample data. When removing the chart, destroy or dispose of its instance and disconnect any resize observer.

Reading This Example

This illustrative weekly review shows Setup at 84 completed tasks against a target of 90, Billing at 67 against 75, and Access at 92 against 85. Access is 7 tasks above target. Setup and Access use range ends of 60, 80, and 100; Billing uses 50, 70, and 100. These are example planning thresholds, not universal performance grades.

All library tabs use these illustrative values. Rendering styles and library APIs differ, so use the table to compare exact numbers.

About this example & sample data
Sample values used in the chart
TeamActual (Tasks)Target (Tasks)Range 1 EndRange 2 EndRange 3 End
Setup84906080100
Billing67755070100
Access92856080100

READ THE CHART

Read Actuals Against a Target and Context

A bullet chart combines a narrow bar for an actual value, a vertical mark for a target, and shaded bands for reference ranges. It puts several pieces of context on one numeric scale without needing a separate gauge for each metric.

01

Separate the Bar, Mark, and Bands

The blue bar starts at zero and ends at the actual value. The dark vertical mark locates the target. Three neutral bands end at the supplied range boundaries. These boundaries are cumulative positions, not amounts to add together.

02

Define What the Ranges Mean

Choose thresholds that are meaningful for the metric and explain them alongside the chart. Shading alone does not establish good or bad performance. Higher completed-task counts suit this illustrative example, but higher values may be undesirable for other measures. A target is independent of the range boundaries.

03

Preserve Comparable Units

All rows use one zero-based scale, so actuals, targets, and boundaries must use the same units. Different metrics with unrelated units need separate charts. The scale expands to include values beyond the final range; it never clips an actual or target to make it look within plan.

04

Handle Boundaries Explicitly

Enter nonnegative values and ordered range ends, with a positive final end. Equal boundaries produce an empty band. Zero actuals have no bar length, while a zero target remains a mark at the origin. Blank cells and reversed boundaries preserve the last valid preview. Use the table and tooltips for exact boundaries.

Edit Chart Data