Bullet Chart

Pro

Featured measure against a comparative target and qualitative bands, horizontal or vertical, on a shared or per-row scale. Pure SVG.

</>sv-bullet-chart chartbulletkpitarget
import { SvBulletChartComponent } from '@styloviz-pro/charts';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Bands and targets

Pro

One shared scale

Pro

Vertical columns

Pro

Row interactions

Pro

A measure the track cannot hold

Pro

When to use

  • A KPI that is only meaningful against a target — actual vs plan, SLA vs commitment.
  • A dense scorecard where one row per metric beats one gauge per metric.
  • For a single headline number with no target, use a Stat Card or a Gauge.

Async states

  • loading
  • empty
  • error
  • retry

Inputs

NameTypeDefaultDescription
loadingbooleanfalseTrue while the rows are being fetched.
errorSvDataError | string | nullnullLoad failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`.
emptyMessagestring'No data to plot'Copy for the no-data state.
filteredbooleanfalseTrue when the emptiness is caused by a filter rather than an empty set.
rowsreadonly BulletRow[][]Rows to plot, one featured measure each.
orientationBulletOrientation'horizontal'Layout direction.
maxnumber0Scale maximum for every row. `0` (the default) derives one from the data. A per-row `max` still wins.
valuePolicySvValuePolicy'clamp'What to do with a measure this chart cannot draw as a length from zero — a negative one, or one that is not a number at all. `clamp` is the default and the only one that keeps the scorecard intact: the bar sits at the baseline, the track is outlined dashed to say the length is not to scale, the REAL number is still printed beside it, and the reason is stated under the plot. `omit` removes the row, which on a scorecard silently removes a metric; `reject` refuses the whole set and shows the error state. `render` is not offered — a track that starts at zero has no room below it.
sharedScalebooleanfalsePut every row on ONE scale so bar lengths are comparable across rows. Off by default, which is the opposite of most multi-row charts and is deliberate: a bullet chart's rows are a scorecard, and a scorecard mixes units — revenue in thousands next to a percentage next to a 1–5 rating. On a shared scale the revenue row sets a ceiling in the thousands and every other row collapses to an invisible sliver at the left edge. Each row already carries its own target and bands, so per-row scales are the honest default; turn this on when the rows genuinely measure the same thing (sales per region, latency per service) and you want lengths comparable. Whichever is in force, the chart states it under the plot — see `scaleNote`.
widthnumber520Chart width in pixels (horizontal); the vertical layout derives its own.
rowHeightnumber46Height of one row band including its spacing, in pixels.
heightnumber260Plot height in pixels, used by the vertical layout.
columnWidthnumber74Width of one column including its spacing, used by the vertical layout.
showLabelsbooleantrueShow the row labels.
showValuesbooleantrueShow the measured value as text.
showTargetbooleantrueDraw the comparative target tick.
titlestring''Chart title, rendered above the plot.
ariaLabelstring''Accessible name override. Falls back to a generated summary.
customClassstring''Extra CSS classes applied to the root wrapper.

Outputs

EventPayload typeDescription
retryvoidThe host should re-run the failed load.
rowHoverBulletRowEventEmitted when a row is hovered or keyboard-focused.
rowSelectBulletRowEventEmitted when a row is clicked or activated with Enter/Space.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `rows`, `loading` and `error`.
  • Set `filtered` when a search or filter is active. It is the difference between "you have no orders" and "no orders match this filter" — telling a customer with 4,000 orders the first one reads as a bug.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Horizontal or vertical layout.
  • Per-row scales by default; opt into a shared or chart-wide one.
  • Per-row band colours, measure colour and unit suffix.
  • Labels, values and the target tick can each be hidden.
  • Reflows in a narrow container instead of scaling. `max-w-full h-auto` shrinks an SVG rather than re-laying it out, so a 520px scorecard in a 300px column draws every 11px label at 6px — unreadable, and zooming does not help because what is on screen is a shrunken glyph, not text at a size. Below 380px the label and value move to their own line above the track, which then runs the full width instead of surrendering the 104px label gutter.
  • The label gutter is sized to the labels rather than fixed, capped at 45 % of the chart, and a name too long for the cap is ellipsised — the full text stays in the row's accessible name, its table row and every emitted event.
  • `width` is the width the host asks for and is only ever capped by the container, never stretched to it: a scorecard spread across a 2000px monitor is not an improvement.
  • The scale mode is **always** stated under the plot, and there is no input to hide it. Per-row scales are the default, so by default each bar is a share of a different number — two bars filling half their tracks are then two entirely different results, and the drawing has no way to say so. On per-row scales each measure is additionally printed as a share of its own ceiling (`268k of 300k`) rather than bare, and the value gutter grows to hold it; with `showValues` off there is no such line, so the note lists each row's ceiling itself. On one shared scale both are dropped: the ceiling is stated once in the note instead of repeated down every row.
  • A measure or a target past the ceiling is still drawn clamped — there is nowhere else to put it — and then MARKED, because a clamp alone is the wrong statement: 340 against a ceiling of 300 drew exactly like 300, and a target on the rim read as "the target is the maximum". A clipped bar gets a chevron at the end of its track and a dashed track outline; an off-scale target tick is dashed; both are named under the plot with the real figure and the ceiling.
  • `valuePolicy` decides what happens to a measure a track starting at zero cannot hold — a negative one, or one that is not a number. `clamp` (the default) pins the bar to the baseline, outlines the track dashed and still prints the real measure, because printing `0` there would be a false measurement. `omit` drops the row, which on a scorecard silently stops reporting a metric, so it says so. `reject` refuses the set and shows the error state. `render` is not offered: a track that starts at zero has no room below it.

Best practices

  • Do: Keep bands to three (poor / satisfactory / good). More reads as decoration.
  • Do: Turn `sharedScale` on when the rows measure the same thing — bar lengths are only comparable on one scale. The chart states which mode is in force under the plot either way, and that line cannot be hidden.
  • Do: Name every band. The band a measure falls in is drawn beside the row, and it is the only reading of the bands that survives a reader who cannot rank three greys by eye.
  • Don't: Do not mix a percentage and a headcount on a shared scale; the small one becomes an invisible sliver. That is why per-row scales are the default.
  • Don't: Do not read a bar with a dashed track outline as a length. It marks a measure past the ceiling or one that cannot be drawn from a zero baseline — the real number is printed beside it and the reason is under the plot.
  • Tip: Bands are cumulative from zero: `{ to: 225 }` means "0 → 225", and they are drawn largest-first so the ramp needs no ordering from you.
  • Tip: A target that is not a number is dropped rather than defaulted, and named under the plot. Passing one used to collapse the whole row to a scale of 1, which drew a full bar and a band ramp painted over itself on a plausible-looking chart.

Accessibility

  • Every row carries a transparent, full-track `role="button"` hit area — a 0 % row has no bar, so the bar itself cannot be the focus target.
  • Each row is named with its value, target, percent-of-target and band, plus its ceiling when the rows disagree on one and a caveat when its bar is not to scale; Enter/Space emits `rowSelect` and focus emits `rowHover`.
  • The band a measure falls in is drawn as TEXT beside the row. Band meaning used to reach the page only as a fill-opacity ramp over one hue — three greys to be ranked by eye and mapped to a legend that did not exist — which is WCAG 1.4.1 on the chart's entire point. The name survives `showValues` and `showLabels` being turned off, since neither hides information the picture carries nowhere else.
  • Bar length against a target tick is the entire chart, so `sv-data-summary-table` publishes measure, value, target and % of target, the band where the rows have one, and a "Scale maximum" column on per-row scales — which is where two equal-LOOKING bars are not equal values. Cells carry the caveats the plot marks visually, so a row whose bar is not to scale is not readable as an ordinary one. A band the source data never named is written by its position — "Band 2 of 3" — beside the row, in its name and in the table, and a dev-mode warning asks for a label; the opacity ramp is never its only statement. A measure that is not a number falls in no band: it says "No value" rather than taking the lowest band it was plotted at.
  • The target tick is drawn over a background-coloured halo. It is a dark 2.5px line that lands on the measure bar whenever the two nearly agree, which on a scorecard is most of the time — against a dark bar colour it vanished, and "we hit the target" drew identically to "there is no target".

Feedback on Bullet Chart

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.