---
title: "RangeLegend"
description: "The key that names the tones a RangeBar draws, so a reader learns in one place what the reference band and each status colour mean. It asserts nothing itself."
url: "https://opsinjs.pensievelabs.org/components/range-legend"
source: "https://opsinjs.pensievelabs.org/components/range-legend.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["key", "chart legend", "band legend"]
governedBy: ["reference-ranges", "two-colour-axes"]
usedIn: ["trends-screen"]
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-legend"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured, and every swatch and glyph is painted on the host's ground rather than a surface the token contract tunes against.&#x22;,
  &#x22;The binding to a RangeBar is a convention rather than a gate, so nothing in the code stops a caller placing the legend away from its bars.&#x22;,
  &#x22;No screen reader user has been asked whether a list of aria-hidden swatches with text labels reads usefully, or whether it needs an accessible name pointing at its bar.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="range-legend" />

## Installation [#installation]

<ComponentInstall name="range-legend" unbuilt="false" importPath="@/components/ui/range-legend" dependencies="[&#x22;lucide-react&#x22;]" />

## Usage [#usage]

```tsx
import { RangeLegend } from "@/components/ui/range-legend"
```

```tsx
<RangeLegend
  bands={[
    { label: "The usual range", description: "From your laboratory. Most results sit here." },
    { label: "Worth watching", tone: "watch" },
    { label: "Needs attention", tone: "attention" },
  ]}
/>
```

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

<WhenToUse
  use="[
  &#x22;Beside RangeBars that share a status vocabulary, naming each level once instead of a pill per row.&#x22;,
  &#x22;As the key beneath a single RangeBar whose status tones a first time reader has not met.&#x22;,
  &#x22;Wherever a reader must learn the neutral band and the status colours before reading the numbers.&#x22;,
]"
  avoid="[
  { case: &#x22;You are showing one measurement against its range; the bar already states reading, range and position.&#x22;, instead: &#x22;range-bar&#x22; },
  { case: &#x22;You want the level of one reading. A legend names the vocabulary, never a verdict.&#x22;, instead: &#x22;status-pill&#x22; },
  { case: &#x22;You are explaining a single clinical term rather than a set of tones.&#x22;, instead: &#x22;term&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** The tones the bar beside it draws, and what each one means. Nothing about any reading, cause or next step.

**Never read as.** A scale, a diagnosis or a verdict on the reader. "Needs attention" names a tone a product may assign, not the reader's result. "Normal" and "abnormal" are banned; [Reference ranges and normal](../health/reference-ranges.mdx) carries the list.

**Colour axis.** The status axis and the neutral reference range band only. No element carries `data-category`, because [The two colour axes](../health/two-colour-axes.mdx) never mix. Every status row draws fill, glyph and word together, so the level survives greyscale.

**Thresholds.** The product owns every word, range and level. RangeLegend ships no band names; words arrive as props and the swatch colours are all it supplies.

## Anatomy [#anatomy]

<Anatomy
  name="range-legend"
  parts="[
  {
    name: &#x22;RangeLegend&#x22;,
    describes: &#x22;The root list. No axis colour, no data.&#x22;,
    prop: &#x22;bands&#x22;,
  },
  {
    name: &#x22;RangeLegend.Item&#x22;,
    describes: &#x22;One row. data-status on a status row, none on the neutral band row.&#x22;,
    prop: &#x22;tone&#x22;,
  },
  {
    name: &#x22;RangeLegend.Swatch&#x22;,
    describes: &#x22;The colour chip in the bar's colour. Outlined for the neutral band, filled for a status.&#x22;,
  },
  {
    name: &#x22;RangeLegend.Label&#x22;,
    describes: &#x22;The row's word, the product's phrasing. The only part a screen reader announces.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;RangeLegend.Glyph&#x22;,
    describes: &#x22;The level's distinct lucide shape, status rows only, in neutral ink. Decorative; the word is the level.&#x22;,
  },
]"
/>

<CompositionTree
  name="range-legend"
  tree="[
  {
    part: &#x22;RangeLegend&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;data-slot=\&#x22;range-legend\&#x22;, a ul.&#x22;,
    children: [
      {
        part: &#x22;RangeLegend.Item&#x22;,
        cardinality: &#x22;1..n&#x22;,
        note: &#x22;data-slot=\&#x22;range-legend-item\&#x22;, a li. data-status=\&#x22;<level>\&#x22; on a status row only.&#x22;,
        children: [
          {
            part: &#x22;RangeLegend.Swatch&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;range-legend-swatch\&#x22;, aria-hidden=\&#x22;true\&#x22;.&#x22;,
          },
          {
            part: &#x22;RangeLegend.Glyph&#x22;,
            cardinality: &#x22;0..1&#x22;,
            note: &#x22;A lucide glyph, aria-hidden=\&#x22;true\&#x22;. Status rows only, no data-slot.&#x22;,
          },
          {
            part: &#x22;RangeLegend.Label&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;data-slot=\&#x22;range-legend-label\&#x22;. The description is a plain span, no slot.&#x22;,
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### The legend beside its bar [#the-legend-beside-its-bar]

The same container as the bars it names, `bands` holding the tones they draw. The swatches match the ticks above.

<ComponentPreview name="range-legend-beside-its-bar" kind="example" align="start" />

### The four levels, named once [#the-four-levels-named-once]

The whole vocabulary in one key: the neutral band and four status levels, each row's `tone` giving its word, glyph and colour.

<ComponentPreview name="range-legend-the-four-levels" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Write each label in the reader's plain words, not a clinical register. The description is a short second line saying who set the range or what the tone means, and the neutral band's word stays about the range, not the reader.

<DoDont>
  <DoDont.Do>
    **"The usual range" with a neutral swatch, then "Worth watching" with its word, glyph and colour.**
  </DoDont.Do>

  <DoDont.Dont>
    **"Normal" with a green swatch and "Abnormal" with a red one.** A verdict on the person, carried by colour alone.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA in a source and a rendered pass. The audit is author-run, not independent, and clinical review is pending.

**What the audit fixed.**

* The legend `<ul>` had no accessible name. Optional `aria-label` and `aria-labelledby` props forward onto the root, so a caller names the key or points it at the bar's heading.
* A blank status label would draw colour and glyph with no word, a colour-alone failure. A guard warns on it, with the empty-bands and invalid-tone guards.

**What still holds from the source.**

* Each swatch and glyph carries `aria-hidden`, so a screen reader skips the decoration.
* No element carries both `data-category` and `data-status`, and this file writes no category colour.
* Nothing takes focus, answers a key or animates, so `prefers-reduced-motion` has no state to reduce.
* At 200% text the swatches hold size, the words wrap, and no ellipsis is set.
* Under `forced-colors: active` a status fill is stripped and the swatch keeps a `CanvasText` outline.

**Residual and deferred.**

* Status swatches are fills of RangeBar's tick tones with no explicit border, so swatch-to-page contrast is unmeasured. Each swatch is `aria-hidden` decoration redundant to the word and glyph, so a low-contrast swatch loses nothing. Measuring the four fills and the neutral border in light and dark is a recommended enhancement.
* Label and description use the foreground and muted-foreground role tokens, not re-measured in this pass.
* The legend cannot enforce in code that it sits beside its bar; `aria-labelledby` is the mechanism a caller uses to bind it, and the audit cannot verify callers do.

<KeyboardTable
  name="range-legend"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Nothing here is focusable.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the whole component&#x22;,
    notes: &#x22;Never in the focus order.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;Swatches are decorative, words static.&#x22;,
  },
]"
/>

<ContrastReport component="range-legend" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="range-legend"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every named inner part, always&#x22;,
    value: &#x22;range-legend, range-legend-item, range-legend-swatch, range-legend-label&#x22;,
  },
  {
    attribute: &#x22;data-status&#x22;,
    condition: &#x22;On a status row's item; absent on the neutral band row&#x22;,
    value: &#x22;The row's tone: steady, watch, attention or urgent&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="RangeLegendProps" />

`tone` is typed to the four `ClinicalStatus` levels in [Clinical status semantics](../health/clinical-status-semantics.mdx). A value outside the four is refused with a development warning and falls back to the neutral band. An empty `bands` array renders nothing and warns in development.

## Related [#related]

* [RangeBar](./range-bar.mdx) renders the bar this legend explains, with its own summary sentence and status pill.
* [StatusPill](./status-pill.mdx) names the level of one reading; RangeLegend names the whole vocabulary a group of bars shares.
* [Term](./term.mdx) explains one clinical word in place, where a legend names a set of tones.
