---
title: "Show a value against a reference range"
description: "Put one measurement next to the range it is compared with, without implying a diagnosis, a verdict or a target."
url: "https://opsinjs.pensievelabs.org/recipes/value-against-a-range"
source: "https://opsinjs.pensievelabs.org/recipes/value-against-a-range.md"
section: "Recipes"
kind: "recipe"
evidence: "mixed"
reviewed: "2026-09-20"
reviewer: "clinical"
implements: ["range-bar", "result-card", "disclaimer-note", "status-pill", "term", "value"]
---

> 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.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="recipe" />

## The task [#the-task]

A person has a measurement and a range it is usually compared against. Your job
is to show where the measurement falls, in a way that is true, legible at a
glance, and readable by somebody who is anxious, distracted, or reading it on a
phone in a car park outside a clinic.

This is the highest-stakes display in the system, and it fails in a specific way.
It is trivially easy to build something that looks like a verdict: a bar that goes
green in the middle and red at the ends, a value that "passes", a range labelled
*normal*. None of those are what a reference range is. A reference range is a
statistical statement about a population. It is usually the central band of
results from a reference group, measured on a particular instrument by a
particular laboratory. It is not a statement about whether you are well, and a
value outside it is not a diagnosis.

<SafetyCallout severity="attention" evidence="opinion">
  A reference range does not partition the world into healthy and ill. Plenty of
  people are outside a range and fine; plenty are inside one and unwell. A display
  that reads as a pass/fail is not a styling preference, it is a false claim, and
  it is the single most common defect in consumer health interfaces.
</SafetyCallout>

## What you need [#what-you-need]

The ten components this recipe composes are all built, installable and
`shipped`: the code exists and installs, and the API may change in any release.
Every opsinjs component has been audited against WCAG 2.2 AA by its own authors;
see [ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx). None has had
an independent accessibility review, and none has had a clinical review.
`shipped` means the source installs and passed that author-run audit, and it does
not mean either independent review has happened. Nothing here is for a production
health surface until a clinician signs it.

| Part                    | Component                                           | Status                           | What it does here                                                        |
| ----------------------- | --------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------ |
| The range display       | [RangeBar](../components/range-bar.mdx)             | <StatusBadge status="shipped" /> | The band, the marker and the endpoint labels.                            |
| The whole result        | [ResultCard](../components/result-card.mdx)         | <StatusBadge status="shipped" /> | Value, range, meaning and next step as one unit.                         |
| The number              | [Value](../components/value.mdx)                    | <StatusBadge status="shipped" /> | Precision, unit adjacency, unit system.                                  |
| The status, if assigned | [StatusPill](../components/status-pill.mdx)         | <StatusBadge status="shipped" /> | Separate from the bar. The bar shows position; the pill shows judgement. |
| Unfamiliar words        | [Term](../components/term.mdx)                      | <StatusBadge status="shipped" /> | Plain-English expansion from the glossary.                               |
| The boundary            | [DisclaimerNote](../components/disclaimer-note.mdx) | <StatusBadge status="shipped" /> | The governed not-medical-advice note, placed by rule.                    |
| What to do              | [CareCard](../components/care-card.mdx)             | <StatusBadge status="shipped" /> | Only when there is a real action with real timing.                       |
| When it was taken       | [RelativeTime](../components/relative-time.mdx)     | <StatusBadge status="shipped" /> | A result with no date is not a result.                                   |
| Endpoint labels         | [RangeLegend](../components/range-legend.mdx)       | <StatusBadge status="shipped" /> | The key to the bands the bar draws, sat beside it.                       |
| Where it came from      | [SourceCitation](../components/source-citation.mdx) | <StatusBadge status="shipped" /> | Where the reading came from, and when it was last checked.               |

Tokens, by family:

| Family               | Where it applies                                                      | Reference                                                             |
| -------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `--opsin-status-*`   | The status pill and, if used at all, the band edge treatment          | [Clinical status semantics](../health/clinical-status-semantics.mdx)  |
| `--opsin-category-*` | The result's identity in a list of results. It never colours the band | [Category identity](../health/category-identity.mdx)                  |
| Contrast floor       | The marker against the band, in both themes                           | [The contrast floor](../foundations/materials/the-contrast-floor.mdx) |
| Type scale           | The value, which is the largest thing on the card                     | [Type scale](../foundations/typography/type-scale.mdx)                |

## Build it [#build-it]

<Steps>
  ### Establish where the range came from [#establish-where-the-range-came-from]

  Before you draw anything, write down four facts: the source of the range, the
  population it describes, the units it is expressed in, and the date or version of
  the source. If you cannot fill in all four, you do not have a reference range.
  You have a pair of numbers, and drawing them as a range asserts an authority
  nobody granted.

  This is a product responsibility, not a component one. opsinjs never supplies
  thresholds, ranges or cut-offs, and a component that appeared to would be a
  defect. See [Reference ranges](../health/reference-ranges.mdx) for the boundary.

  ### Convert units once, at the edge [#convert-units-once-at-the-edge]

  Decide the canonical unit for the measurement, convert on the way in, and store
  that. Never convert in the view. A range in mmol/L drawn against a value in mg/dL
  is off by a factor of eighteen and looks entirely plausible.

  [Unit systems](../health/unit-systems.mdx) treats this as a correctness surface
  rather than a localisation one, for exactly this reason. When the reader switches
  unit system, the value, both endpoints and the marker position all move together
  or none of them do.

  ### Draw position, not verdict [#draw-position-not-verdict]

  The bar's job is to answer "where does my number sit relative to this band?" and
  nothing else. Concretely:

  * The band is one continuous shape with visible endpoints. It is not divided into
    coloured zones, because zones read as grades.
  * The marker is a distinct shape placed at the value, and a colour change alone
    is not one. It must be findable in greyscale.
  * The axis is not truncated to make a small difference look large.
  * If the value is far outside the band, the axis extends or the marker pins to
    the edge with an explicit "beyond the scale" treatment. It never silently
    clamps, because a clamped marker reads as *just outside* when it may be far
    outside.

  ### Keep the judgement out of the bar [#keep-the-judgement-out-of-the-bar]

  If your product's clinical rules assigned a status, it appears as a
  [StatusPill](../components/status-pill.mdx) beside the bar, with its word, its
  icon and its colour. It does not recolour the band, the marker or the card.

  Two reasons. First, the never-mix rule: the bar is a measurement display and the
  pill is a status display, and merging them makes the status impossible to remove
  when no rule assigned one. Second, honesty: a great many results have no assigned
  status, and a design that only looks finished when it is coloured will get
  coloured.

  ### Label the endpoints in words [#label-the-endpoints-in-words]

  The numeric endpoints are necessary and not sufficient. Add the sentence that
  says what the band is: "The usual range for this test in adults is 3.9 to 5.6
  mmol/L." That sentence is the accessible description, the print fallback and the
  thing a reader repeats to somebody else.

  Never label the band *normal*. Use *usual range*, *reference range*, or *the
  range this test is compared against*. The ban and its reasoning are in
  [Reference ranges](../health/reference-ranges.mdx).

  ### Write the text alternative before the graphic [#write-the-text-alternative-before-the-graphic]

  The bar is an enhancement. Write the sentence that carries the entire meaning
  first, then check that the graphic adds speed rather than information. If the
  graphic carries something the sentence does not, a screen-reader user, a print
  reader and a reader at 200% text are all missing it.

  [Alt text and descriptions](../content/alt-text-and-descriptions.mdx) covers
  describing dials, ranges and body maps non-visually.

  ### Say what happens next even when nothing does [#say-what-happens-next-even-when-nothing-does]

  Every result surface answers "so what do I do?". For most results the honest
  answer is *nothing, and here is when this will next be measured*. Say it. A
  result screen that ends in silence is read as a screen that is withholding
  something, which produces exactly the anxiety the design is trying to avoid.

  When there is a real action with real timing, it is a
  [CareCard](../components/care-card.mdx): what to do, by when, and what happens if
  it is not done.
</Steps>

## The copy [#the-copy]

The result sentence has a fixed shape. Fill it in; do not improvise it.

> **Your `<measurement>` was `<value> <unit>`, measured `<when>`.**
> &#x2A;*The usual range for this test is `<low>` to `<high>` `<unit>`.*&#x2A;
> &#x2A;*`<What this does or does not mean, in one sentence.>`*&#x2A;
> &#x2A;*`<What happens next, or that nothing needs to happen.>`**

<DoDont>
  <DoDont.Do>
    "Your fasting blood glucose was 6.1 mmol/L, measured on 14 March. The usual
    range for this test is 3.9 to 5.6 mmol/L. A single result above the range does
    not on its own mean you have a condition. Your clinic will repeat this test."
  </DoDont.Do>

  <DoDont.Dont>
    "Glucose: 6.1 HIGH ⚠️ (Normal: 3.9 to 5.6). Your result is abnormal." Four
    faults: a banned word, a verdict the data does not support, shouting, and no
    statement of what happens next.
  </DoDont.Dont>
</DoDont>

**Banned words on this surface:** *normal*, *abnormal*, *good*, *bad*, *poor*,
*failed*, *passed*, *dangerous*, *critical* (unless it is the assigned status word
and your product owns that rule). Each is banned because it asserts a judgement
the range cannot support, and each has a replacement: describe the position
("above the usual range"), not the person.

**Never use an exclamation mark, capitals, or an emoji to signal severity.**
Severity is carried by the status vocabulary and the surface it appears on. See
[Writing status and alerts](../content/writing-status-and-alerts.mdx).

## Get it right [#get-it-right]

**Safety**

* The screen states where the range came from, or does not draw a range.
* The value and both endpoints are in the same unit, from the same conversion.
* Out of range is described as position, never as a verdict.
* No component computed the status. If a status is displayed, you can name the
  rule and the owner of that rule.
* Nothing on the screen implies a trend from a single measurement. One point is
  not a direction. See [Trends and change](../health/trends-and-change.mdx).
* The disclaimer is placed by the rule in
  [DisclaimerNote](../components/disclaimer-note.mdx), not wherever it fits.
* If this result could be distressing, the way it is revealed matters as much as
  the way it is drawn:
  [Result disclosure](../patterns/result-disclosure.mdx).

**Accessibility**

* The text alternative carries the whole meaning: value, unit, range, position,
  and what happens next.
* The marker is distinguishable from the band by shape and by contrast, verified
  in both themes against the floor published in
  [Contrast conformance](../accessibility/contrast-conformance.mdx).
* The result survives greyscale and all three simulated colour-vision
  deficiencies. [Colour independence](../accessibility/colour-independence.mdx)
  sets that bar.
* At 200% text the bar reflows or gives way to the sentence; it never truncates
  the value. [Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx)
  covers the reflow behaviour.
* The value never animates into place. A number that counts up is a number the
  reader cannot read, and under `prefers-reduced-motion` it must simply be
  present. [Motion in health UI](../health/motion-in-health-ui.mdx) is the
  governing rule.
* Any [Term](../components/term.mdx) expansion is reachable by keyboard and does
  not depend on hover.

<ResearchNote evidence="mixed" date="2026-09-02">
  The rules above are largely **opinion**, held for the reasons given, and stated so
  that they can be argued with: the ban on *normal*, the separation of the bar from
  the status pill, and the fixed sentence order are design positions, not findings.

  There is a real literature on how laypeople read results, and it is worth reading
  before changing any of them. Two open anchors: a 2024 JMIR systematic review of
  lab-result presentation formats for patients (doi:10.2196/53993), and a 2018 study
  of patient-portal result presentation in BMC Medical Informatics and Decision
  Making (doi:10.1186/s12911-018-0589-7). This page deliberately does not summarise
  their findings, and no claim on this page should be attributed to either. Read
  them rather than trusting a paraphrase.
</ResearchNote>

## Variations [#variations]

**The range depends on the person.** Age, sex, pregnancy, medication and assay
all shift reference intervals. If your range is conditional, the condition is part
of the label: "the usual range for adults over 18". If you cannot state the
condition, you cannot state the range.

**There is no range.** Common, and a legitimate state. Show the value, say plainly
that this test has no range to compare against here, and do not draw an empty
bar. An empty bar reads as a missing result rather than as a missing range. This
is the **partial** data state from
[Data states](../foundations/data-states.mdx).

**There are two bands.** A reference range and a personal target are different
claims and must not share an axis without being distinguished in words as well as
visually. If you cannot make the distinction clear in one sentence, show one.

**The value is a category, not a number.** Detected/not detected, positive/negative.
There is no range and no bar; this is a
[ResultCard](../components/result-card.mdx) with a value, a plain-English
expansion of what the words mean, and what happens next.

**The result is one of many.** A panel of twenty results is a list, and a list of
twenty bars is unreadable. Show the value and the position in words, and let the
reader open the one they care about.

<Todo>
  No recipe covers a full results panel end to end. That is the list-of-many case.
  The screen specimen closest to it is
  [Results screen](../screens/results-screen.mdx).
</Todo>

## Related [#related]

* **[Reference ranges](../health/reference-ranges.mdx)** is the doctrine this
  recipe implements, including why *normal* is banned outright.
* **[RangeBar](../components/range-bar.mdx)** has the component specification, its
  proposed API and the accessibility bar it must clear.
* **[Result disclosure](../patterns/result-disclosure.mdx)** covers the flow
  around this screen, for results somebody may not want to see.
