RangeLegend
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.
Preview
range-legend · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/range-legend
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { RangeLegend } from "@/components/ui/range-legend"<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
Use it when
- Beside RangeBars that share a status vocabulary, naming each level once instead of a pill per row.
- As the key beneath a single RangeBar whose status tones a first time reader has not met.
- Wherever a reader must learn the neutral band and the status colours before reading the numbers.
Do not use it when
- You are showing one measurement against its range; the bar already states reading, range and position.Use
range-barinstead. - You want the level of one reading. A legend names the vocabulary, never a verdict.Use
status-pillinstead. - You are explaining a single clinical term rather than a set of tones.Use
terminstead.
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 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 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
RangeLegendThe root list. No axis colour, no data.Controlled bybandsRangeLegend.ItemOne row. data-status on a status row, none on the neutral band row.Controlled bytoneRangeLegend.SwatchThe colour chip in the bar's colour. Outlined for the neutral band, filled for a status.RangeLegend.LabelThe row's word, the product's phrasing. The only part a screen reader announces.Controlled bylabelRangeLegend.GlyphThe level's distinct lucide shape, status rows only, in neutral ink. Decorative; the word is the level.
- RangeLegend 1data-slot="range-legend", a ul.
- RangeLegend.Item 1..ndata-slot="range-legend-item", a li. data-status="<level>" on a status row only.
- RangeLegend.Swatch 1data-slot="range-legend-swatch", aria-hidden="true".
- RangeLegend.Glyph 0..1A lucide glyph, aria-hidden="true". Status rows only, no data-slot.
- RangeLegend.Label 1data-slot="range-legend-label". The description is a plain span, no slot.
- RangeLegend.Item 1..ndata-slot="range-legend-item", a li. data-status="<level>" on a status row only.
Examples
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.
range-legend-beside-its-bar · base base · style base-lyraOpen under the product themeThe 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.
range-legend-the-four-levels · base base · style base-lyraOpen under the product themeContent 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.
Do
"The usual range" with a neutral swatch, then "Worth watching" with its word, glyph and colour.
Don’t
"Normal" with a green swatch and "Abnormal" with a red one. A verdict on the person, carried by colour alone.
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. Optionalaria-labelandaria-labelledbyprops 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-categoryanddata-status, and this file writes no category colour. - Nothing takes focus, answers a key or animates, so
prefers-reduced-motionhas no state to reduce. - At 200% text the swatches hold size, the words wrap, and no ellipsis is set.
- Under
forced-colors: activea status fill is stripped and the swatch keeps aCanvasTextoutline.
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-hiddendecoration 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-labelledbyis the mechanism a caller uses to bind it, and the audit cannot verify callers do.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the whole component | Nothing here is focusable. |
| Shift+Tab | Skips the whole component | Never in the focus order. |
| Arrow keys | Nothing | Swatches are decorative, words static. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| attention ink on attention surface | light | 89.4 | 10.35:1 | Pass |
| attention ink on attention surface | dark | -81.1 | 12.54:1 | Pass |
| attention line on attention surface | light | 70.0 | 4.90:1 | Pass |
| attention line on attention surface | dark | -65.5 | 9.54:1 | Pass |
| attention accent on attention surface | light | 65.1 | 4.18:1 | Pass |
| attention accent on attention surface | dark | -49.4 | 6.80:1 | Pass |
| attention ink on the attention band | light | 22.4 | 2.48:1 | Below floor |
| attention ink on the attention band | dark | -31.0 | 1.84:1 | Below floor |
| attention accent on the page | light | 67.8 | 4.35:1 | Pass |
| attention accent on the page | dark | -50.5 | 7.47:1 | Pass |
| attention ink on the page | light | 92.2 | 10.77:1 | Pass |
| attention ink on the page | dark | -82.3 | 13.76:1 | Pass |
| steady ink on steady surface | light | 88.3 | 9.62:1 | Pass |
| steady ink on steady surface | dark | -82.9 | 12.56:1 | Pass |
| steady line on steady surface | light | 67.5 | 4.45:1 | Pass |
| steady line on steady surface | dark | -68.9 | 9.91:1 | Pass |
| steady accent on steady surface | light | 56.4 | 3.21:1 | Pass |
| steady accent on steady surface | dark | -53.2 | 7.25:1 | Pass |
| steady ink on the steady band | light | 30.0 | 3.00:1 | Below floor |
| steady ink on the steady band | dark | -28.8 | 1.73:1 | Below floor |
| steady accent on the page | light | 58.6 | 3.31:1 | Pass |
| steady accent on the page | dark | -54.5 | 8.19:1 | Pass |
| steady ink on the page | light | 90.6 | 9.94:1 | Pass |
| steady ink on the page | dark | -84.2 | 14.20:1 | Pass |
| unknown ink on unknown surface | light | 89.5 | 9.98:1 | Pass |
| unknown ink on unknown surface | dark | -82.2 | 12.56:1 | Pass |
| unknown line on unknown surface | light | 69.2 | 4.64:1 | Pass |
| unknown line on unknown surface | dark | -67.2 | 9.72:1 | Pass |
| unknown accent on unknown surface | light | 43.1 | 2.29:1 | Below floor |
| unknown accent on unknown surface | dark | -51.3 | 7.05:1 | Pass |
| unknown accent on the page | light | 45.6 | 2.37:1 | Below floor |
| unknown accent on the page | dark | -52.3 | 7.84:1 | Pass |
| unknown ink on the page | light | 92.0 | 10.34:1 | Pass |
| unknown ink on the page | dark | -83.1 | 13.97:1 | Pass |
| urgent ink on urgent surface | light | 88.2 | 10.58:1 | Pass |
| urgent ink on urgent surface | dark | -80.3 | 12.52:1 | Pass |
| urgent line on urgent surface | light | 70.0 | 5.09:1 | Pass |
| urgent line on urgent surface | dark | -64.8 | 9.52:1 | Pass |
| urgent accent on urgent surface | light | 72.3 | 5.58:1 | Pass |
| urgent accent on urgent surface | dark | -48.0 | 6.63:1 | Pass |
| urgent ink on the urgent band | light | 13.9 | 1.90:1 | Below floor |
| urgent ink on the urgent band | dark | -31.5 | 1.89:1 | Below floor |
| urgent accent on the page | light | 75.3 | 5.84:1 | Pass |
| urgent accent on the page | dark | -49.5 | 7.24:1 | Pass |
| urgent ink on the page | light | 91.2 | 11.06:1 | Pass |
| urgent ink on the page | dark | -81.8 | 13.66:1 | Pass |
| watch ink on watch surface | light | 89.4 | 10.02:1 | Pass |
| watch ink on watch surface | dark | -82.1 | 12.58:1 | Pass |
| watch line on watch surface | light | 69.5 | 4.72:1 | Pass |
| watch line on watch surface | dark | -66.8 | 9.68:1 | Pass |
| watch accent on watch surface | light | 61.7 | 3.72:1 | Pass |
| watch accent on watch surface | dark | -50.7 | 6.95:1 | Pass |
| watch ink on the watch band | light | 25.7 | 2.70:1 | Below floor |
| watch ink on the watch band | dark | -30.6 | 1.81:1 | Below floor |
| watch accent on the page | light | 64.2 | 3.85:1 | Pass |
| watch accent on the page | dark | -51.7 | 7.71:1 | Pass |
| watch ink on the page | light | 91.9 | 10.38:1 | Pass |
| watch ink on the page | dark | -83.1 | 13.95:1 | Pass |
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| attention surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| attention surface on the card material | dark | 0.0 | 1.00:1 | Below floor |
| steady surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| steady surface on the card material | dark | 0.0 | 1.03:1 | Below floor |
| urgent surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| urgent surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| watch surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| watch surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| the focus ring on the attention surface | light | 67.3 | 4.44:1 | Pass |
| the focus ring on the attention surface | dark | -45.9 | 6.30:1 | Pass |
| attention ink on the neutral hover fill | light | 88.6 | 10.23:1 | Pass |
| attention ink on the neutral hover fill | dark | -79.3 | 10.53:1 | Pass |
| the focus ring on the steady surface | light | 67.8 | 4.47:1 | Pass |
| the focus ring on the steady surface | dark | -45.8 | 6.12:1 | Pass |
| steady ink on the neutral hover fill | light | 87.0 | 9.43:1 | Pass |
| steady ink on the neutral hover fill | dark | -81.3 | 10.86:1 | Pass |
| the focus ring on the urgent surface | light | 67.0 | 4.42:1 | Pass |
| the focus ring on the urgent surface | dark | -45.5 | 6.34:1 | Pass |
| urgent ink on the neutral hover fill | light | 87.7 | 10.50:1 | Pass |
| urgent ink on the neutral hover fill | dark | -78.9 | 10.46:1 | Pass |
| the focus ring on the watch surface | light | 67.6 | 4.46:1 | Pass |
| the focus ring on the watch surface | dark | -46.0 | 6.24:1 | Pass |
| watch ink on the neutral hover fill | light | 88.3 | 9.86:1 | Pass |
| watch ink on the neutral hover fill | dark | -80.1 | 10.67:1 | Pass |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | On the root and every named inner part, always | range-legend, range-legend-item, range-legend-swatch, range-legend-label |
| data-status | On a status row's item; absent on the neutral band row | The row's tone: steady, watch, attention or urgent |
API reference
Prop
Type
Generated from RangeLegendProps in registry/bases/base/range-legend.tsx.
tone is typed to the four ClinicalStatus levels in Clinical status semantics. 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
- RangeBar renders the bar this legend explains, with its own summary sentence and status pill.
- StatusPill names the level of one reading; RangeLegend names the whole vocabulary a group of bars shares.
- Term explains one clinical word in place, where a legend names a set of tones.
StatusPill
A short label that says which of four levels of attention something needs. It is shown as a word, an icon and a colour together, never as a colour alone.
TimelineEntry
One dated event in a vertical history, with a neutral rail and marker, the time written the way people say it, and any clinical status shown as a nested pill.