---
title: "RangeBar"
description: "A single measurement drawn on a line, so you can see at a glance where your number sits compared with the range it is usually expected to fall in."
url: "https://opsinjs.pensievelabs.org/components/range-bar"
source: "https://opsinjs.pensievelabs.org/components/range-bar.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["reference range", "normal range", "in range", "range indicator", "gauge bar"]
governedBy: ["two-colour-axes", "reference-ranges", "numbers-units-precision", "unit-systems"]
usedIn: ["choose-a-component", "result-disclosure", "results-screen", "trend-review", "value-against-a-range"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="range-bar"
  status="shipped"
  questions="[
  &#x22;The track is aria-hidden, so the summary alone announces the reading, and no screen-reader user has been asked whether the picture should be named.&#x22;,
  &#x22;Nothing is measured: the status word and category tint are ink roles on the host's ground.&#x22;,
  &#x22;Two boundary labels can round to the same number, and the component warns in development rather than adding a decimal place nobody claimed.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="range-bar" />

## Installation [#installation]

<ComponentInstall name="range-bar" unbuilt="false" importPath="@/components/ui/range-bar" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;value&#x22;, &#x22;status-pill&#x22;]" />

## Usage [#usage]

```tsx
import { RangeBar } from "@/components/ui/range-bar"
```

```tsx
<RangeBar
  label="Example measurement"
  value={14}
  unit="mg/dL"
  precision={0}
  locale="en-GB"
  range={{ low: 10, high: 20, source: "Example data rather than a reference range" }}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;One measurement with a published reference range, shown to its owner.&#x22;,
  &#x22;A value whose meaning is positional rather than absolute.&#x22;,
  &#x22;A reader who would otherwise compare two numbers in their head.&#x22;,
]"
  avoid="[
  { case: &#x22;There is no range, or it turns on facts you lack: age, sex, medication, assay.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;You are showing change over time, not position.&#x22;, instead: &#x22;trend-sparkline&#x22; },
  { case: &#x22;The number is a score you calculated, not a measurement.&#x22;, instead: &#x22;score-dial&#x22; },
  { case: &#x22;Only the clinical status level matters.&#x22;, instead: &#x22;status-pill&#x22; },
  { case: &#x22;You want to say what to do about it.&#x22;, instead: &#x22;care-card&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** This measurement, in this [unit](../health/unit-systems.mdx), at this
position against this range, from this source. Nothing about cause or trend.

**Never read as.** A diagnosis or verdict. Outside the range is not "abnormal", inside
is not "normal": [Reference ranges](../health/reference-ranges.mdx) has the banned words.

**Colour axis.** Status only, when the product supplies one. `category` tints the label
alone; position never becomes a status.
[The two colour axes](../health/two-colour-axes.mdx).

**Thresholds.** The product owns every range, status and
[precision](../health/numbers-units-precision.mdx). No built-in table, no
`staleAfterHours`. Staleness comes from [RelativeTime](./relative-time.mdx).

<SafetyCallout severity="urgent" evidence="opinion">
  Given no range, or one with neither bound or an empty `source`, RangeBar draws no band
  and substitutes no default.
</SafetyCallout>

## Anatomy [#anatomy]

<Anatomy
  name="range-bar"
  parts="[
  { name: &#x22;RangeBar&#x22;, describes: &#x22;The root. Colour from neither axis.&#x22; },
  { name: &#x22;RangeBar.Label&#x22;, describes: &#x22;What was measured, the only category-tinted part.&#x22;, prop: &#x22;category&#x22; },
  { name: &#x22;StatusPill&#x22;, describes: &#x22;The level the product assigned.&#x22;, prop: &#x22;status&#x22; },
  { name: &#x22;RangeBar.Track&#x22;, describes: &#x22;The drawn extent, aria-hidden, never a slider.&#x22;, prop: &#x22;range&#x22; },
  { name: &#x22;RangeBar.Band&#x22;, describes: &#x22;The range itself, outlined, never axis-coloured.&#x22; },
  { name: &#x22;RangeBar.Mark&#x22;, describes: &#x22;A boundary and its number, at the metric's precision.&#x22;, prop: &#x22;precision&#x22; },
  { name: &#x22;RangeBar.Tick&#x22;, describes: &#x22;The reading's position, the only status-coloured part.&#x22;, prop: &#x22;value&#x22; },
  { name: &#x22;RangeBar.Value&#x22;, describes: &#x22;The reading and its unit, for the eye alone.&#x22;, prop: &#x22;unit&#x22; },
  { name: &#x22;RangeBar.Summary&#x22;, describes: &#x22;Reading, position, range and source. Rewordable, never removable.&#x22;, prop: &#x22;summary&#x22; },
  { name: &#x22;RangeBar.Footnote&#x22;, describes: &#x22;When the reading was taken, or that nobody knows.&#x22;, prop: &#x22;measuredAt&#x22; },
]"
/>

<CompositionTree
  name="range-bar"
  tree="[
  {
    part: &#x22;RangeBar&#x22;,
    cardinality: &#x22;1&#x22;,
    children: [
      { part: &#x22;RangeBar.Label&#x22;, cardinality: &#x22;1&#x22; },
      { part: &#x22;StatusPill&#x22;, cardinality: &#x22;0..1&#x22; },
      {
        part: &#x22;RangeBar.Track&#x22;,
        cardinality: &#x22;0..1&#x22;,
        children: [
          { part: &#x22;RangeBar.Band&#x22;, cardinality: &#x22;1&#x22; },
          { part: &#x22;RangeBar.Mark&#x22;, cardinality: &#x22;2&#x22; },
          {
            part: &#x22;RangeBar.Tick&#x22;,
            cardinality: &#x22;1&#x22;,
            children: [{ part: &#x22;RangeBar.Value&#x22;, cardinality: &#x22;1&#x22; }],
          },
        ],
      },
      { part: &#x22;RangeBar.Summary&#x22;, cardinality: &#x22;1&#x22; },
      { part: &#x22;RangeBar.Footnote&#x22;, cardinality: &#x22;0..1&#x22; },
    ],
  },
]"
/>

## Examples [#examples]

### Beyond the top of the range [#beyond-the-top-of-the-range]

The tick sits outside the band, and that is not a verdict.

<ComponentPreview name="range-bar-outside-the-range" kind="example" align="start" />

### One bound only [#one-bound-only]

One bound has no width, so no track is drawn. The summary names the direction.

<ComponentPreview name="range-bar-an-open-ended-range" kind="example" align="start" />

### Three readings down a page [#three-readings-down-a-page]

`category` on the labels, `status` on the ticks, no element taking both.

<ComponentPreview name="range-bar-in-a-list-of-results" kind="example" align="start" />

### Far above the range [#far-above-the-range]

The extent stretches to contain `value`, so a far reading squeezes the band.

<ComponentPreview name="range-bar-far-above-the-range" kind="example" align="start" />

## States [#states]

| Data state | What RangeBar renders                                                                                    |
| ---------- | -------------------------------------------------------------------------------------------------------- |
| Error      | It owns no fetch. A range with no `source`, neither bound, or downward bounds is discarded and reported. |
| Loading    | No skeleton or spinner; renders synchronously.                                                           |
| Empty      | `value` of `null` differs from `0`: no tick, and the summary names the range.                            |
| Partial    | No range, or one bound, draws no track. The summary carries the comparison.                              |
| Stale      | It states when the reading was taken, or that nobody knows. No boundary.                                 |

## Content guidelines [#content-guidelines]

The label is what was measured, in the reader's words. The summary states position,
never a verdict.

<DoDont>
  <DoDont.Do>
    **"26 mg/dL, above the range 10 to 20 mg/dL, from Example data."**
  </DoDont.Do>

  <DoDont.Dont>
    **"26 mg/dL. Abnormal. Your result is poor."** A verdict with no basis.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA** over the source and the rendered preview. The audit is
author-run, not independent, and clinical review is pending. The rendered pass was
clean: no axe violations, no focus-visible failure, no horizontal scroll at 320px. It is
display-only, so nothing is focusable and the keyboard and target-size checks do not
apply.

* The summary paragraph is unconditional: reading, range with its source, and within,
  above or below in words, so the picture is never named.
* `RangeBar.Track` is `aria-hidden`, with no `slider`, `meter` or `progressbar` role.
* Numbers go through [Value](./value.mdx), so the unit is spoken, not spelled.
* Status carries a word and a glyph through StatusPill, redundant to the tick colour.
* Under `forced-colors` the tick keeps a `Highlight` fill and `CanvasText` outline.

**Residual.** The band outline and the two boundary marks fall below the 3:1 non-text
contrast floor. They stay because the graphic is `aria-hidden` and the summary carries
the same facts. Its own text and colour pairs are not measured yet, so the report
below stands in.

<ContrastReport component="range-bar" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="range-bar"
  rows="[
  { attribute: &#x22;data-slot&#x22;, condition: &#x22;On every part, always&#x22;, value: &#x22;range-bar, range-bar-label, status-pill, range-bar-track, range-bar-band, range-bar-mark, range-bar-tick, range-bar-value, range-bar-summary, range-bar-footnote&#x22; },
  { attribute: &#x22;data-status&#x22;, condition: &#x22;On the StatusPill and the tick, when a status was supplied&#x22;, value: &#x22;steady | watch | attention | urgent&#x22; },
  { attribute: &#x22;data-category&#x22;, condition: &#x22;On the label, when a category was supplied&#x22;, value: &#x22;sleep | heart | activity | nutrition | mind | labs&#x22; },
  { attribute: &#x22;data-opsinjs-value&#x22;, condition: &#x22;On every Value, six per drawn bar&#x22;, value: &#x22;The unrounded number, or empty with no reading&#x22; },
]"
/>

## CSS variables [#css-variables]

<CssVariablesTable
  name="range-bar"
  rows="[
  { selector: &#x22;div:has(> [data-slot=\&#x22;range-bar-track\&#x22;])&#x22;, variable: &#x22;--rb-label-half&#x22;, controls: &#x22;How far the reading label's midpoint is clamped from either track edge. Set inline at 3.5em.&#x22; },
]"
/>

## API reference [#api-reference]

<PropsTable name="RangeBarProps" />

`precision` is decimal places, not significant figures, and it is required. A `status`
outside the four levels is dropped, never approximated. Omit `locale` and the server
formats with its own while the browser uses the reader's, so the two disagree.

## Related [#related]

* [ScoreDial](./score-dial.mdx), a calculated score against its bands.
* [TrendSparkline](./trend-sparkline.mdx), change over time.
* [Progress](./progress.mdx), progress towards a goal.
* [StatusPill](./status-pill.mdx), the level alone.
* [MetricTile](./metric-tile.mdx), the compact dashboard form.
