---
title: "Choose a component"
description: "Start from the data you have and end with the component that may honestly display it."
url: "https://opsinjs.pensievelabs.org/recipes/choose-a-component"
source: "https://opsinjs.pensievelabs.org/recipes/choose-a-component.md"
section: "Recipes"
kind: "recipe"
evidence: "opinion"
reviewed: "2026-09-20"
reviewer: "engineering"
implements: ["result-card", "range-bar", "score-dial", "trend-sparkline", "metric-tile", "status-pill", "alert-banner", "care-card", "term", "value", "relative-time", "disclaimer-note", "callout", "empty-state", "skeleton", "card", "surface"]
---

> 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]

You have a piece of health data, and it is a measurement, a derived index, a
series, a state or a word. You have to decide what to render. The wrong choice
here is not a styling problem. Putting a status colour on a category component
implies a judgement nobody made; putting a score dial on a single measurement
implies a composite that does not exist; putting an alert banner on information
nobody has to act on spends the escalation budget on nothing and makes the next
real alert invisible.

This recipe produces a decision, not a surface. Every other recipe assumes you
have already made it.

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

Nothing to install for this recipe itself. It ends in a decision, not a
surface. What you need is the shape of your datum and two facts about it: **who
assigned any clinical status it carries**, and **whether the reader needs the
reference range to understand it**. If you cannot answer the first question, you
do not yet have a component decision to make. You have a clinical rules
question, and the answer belongs in your product, not in the UI.

The components below are the full display roster, and every one of them is
`shipped`: the code exists and installs, and the API may change in any release.
Every one of them has been audited against WCAG 2.2 AA by its own authors; see
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for what that
audit checked and why an author-run audit is not an independent review. No
opsinjs component has had an independent accessibility review, and none has had
a clinical review. Nothing here is for a production health surface until a
clinician signs it. Read the fourth column before you install anything. The
wrong choice compiles exactly as well as the right one.

| What you have                                          | Component                                                             | Status                           | Why not the obvious alternative                                                                      |
| ------------------------------------------------------ | --------------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- |
| One measurement the reader is meant to interpret       | [ResultCard](../components/result-card.mdx)                           | <StatusBadge status="shipped" /> | Not MetricTile: a tile is a glance, a result is a reading with meaning attached.                     |
| One measurement whose position in a range is the point | [RangeBar](../components/range-bar.mdx)                               | <StatusBadge status="shipped" /> | Not ScoreDial: a dial implies a composite index, a range bar shows one measured value.               |
| A composite index derived from several inputs          | [ScoreDial](../components/score-dial.mdx)                             | <StatusBadge status="shipped" /> | Not Value: a number whose derivation is hidden must show that it is derived.                         |
| The same measurement over time                         | [TrendSparkline](../components/trend-sparkline.mdx)                   | <StatusBadge status="shipped" /> | Not a chart library: the y-axis rules and the honest accessible description are the component's job. |
| One number at a glance, on a dashboard                 | [MetricTile](../components/metric-tile.mdx)                           | <StatusBadge status="shipped" /> | Not ResultCard: a tile that carries interpretation is a result card wearing the wrong size.          |
| A clinical status on its own                           | [StatusPill](../components/status-pill.mdx)                           | <StatusBadge status="shipped" /> | Not Callout: a callout is outside the status axis by design.                                         |
| Something the reader has to read now                   | [AlertBanner](../components/alert-banner.mdx)                         | <StatusBadge status="shipped" /> | Not Dialog: interruption is the last rung, not the first.                                            |
| Something the reader has to *do*, and by when          | [CareCard](../components/care-card.mdx)                               | <StatusBadge status="shipped" /> | Not AlertBanner: a banner states, a care card instructs.                                             |
| A word the reader may not know                         | [Term](../components/term.mdx)                                        | <StatusBadge status="shipped" /> | Not a tooltip: the expansion must survive keyboard, touch and print.                                 |
| A number that needs formatting                         | [Value](../components/value.mdx)                                      | <StatusBadge status="shipped" /> | Not string interpolation: precision, unit adjacency and locale are correctness.                      |
| A timestamp                                            | [RelativeTime](../components/relative-time.mdx)                       | <StatusBadge status="shipped" /> | Not a formatted date: staleness thresholds are per metric and belong in the component.               |
| Ordinary information, no clinical grading              | [Callout](../components/callout.mdx)                                  | <StatusBadge status="shipped" /> | Not StatusPill: the status axis is reserved for clinical meaning.                                    |
| The standard not-medical-advice note                   | [DisclaimerNote](../components/disclaimer-note.mdx)                   | <StatusBadge status="shipped" /> | Not free prose: placement and wording are governed, so the note is a component.                      |
| Nothing to show yet                                    | [EmptyState](../components/empty-state.mdx)                           | <StatusBadge status="shipped" /> | Not a zero: a zero is a measurement, an absence is not.                                              |
| Data on its way                                        | [Skeleton](../components/skeleton.mdx)                                | <StatusBadge status="shipped" /> | Not a spinner over stale content: never let an old value read as a new one.                          |
| A bounded group of the above                           | [Card](../components/card.mdx) · [Surface](../components/surface.mdx) | <StatusBadge status="shipped" /> | Card is the content group; Surface is the material rung underneath it.                               |

## Build it [#build-it]

<Steps>
  ### Name the datum in the reader's words [#name-the-datum-in-the-readers-words]

  Before anything else, write the label you would say out loud. "Resting heart
  rate", not `rhr`. "How long you slept", not "sleep duration total minutes". If
  the plain-English name needs a clinical term, that term is a
  [Term](../components/term.mdx) with an expansion, not a word you leave for the
  reader to look up. The [Plain-English A to Z](../content/plain-english-a-z.mdx)
  is the canonical vocabulary.

  If you cannot name it in the reader's words, the problem is not the component.

  ### Decide whether it carries a clinical status [#decide-whether-it-carries-a-clinical-status]

  A datum carries clinical status only if a rule your product owns assigned one:
  steady, watch, attention or urgent. The four levels and what each may mean are
  defined in
  [Clinical status semantics](../health/clinical-status-semantics.mdx).

  Three things that are **not** a status: a category (heart, sleep, activity), a
  direction of change, and a position relative to a reference range. A value
  outside a reference range is outside a reference range. Whether that is *watch*
  or *attention* is a clinical judgement, and if nobody in your product has made
  it, the answer is that the datum has no status and no status colour appears.

  ### Decide whether the reader needs the range [#decide-whether-the-reader-needs-the-range]

  Most lab results and most single readings are meaningless without knowing what
  they are being compared to. For those, the range is part of the datum, not a
  detail, and the component has to carry it. That points at
  [ResultCard](../components/result-card.mdx) or
  [RangeBar](../components/range-bar.mdx), and at the
  [Show a value against a reference range](./value-against-a-range.mdx) recipe.

  If the reader already knows what good looks like for this number, the range is
  noise and a [MetricTile](../components/metric-tile.mdx) is enough. Steps today
  and hours slept are examples.

  ### Pick from the table [#pick-from-the-table]

  Match the shape, then read the fourth column. The fourth column is the useful
  one: it names the component you were about to reach for instead, and the reason
  that would have been wrong. Most incorrect choices are one of those pairs.

  ### Check the two axes [#check-the-two-axes]

  Look at every coloured element in what you have chosen and say which axis it is
  on. Category colours identify. They say *this is your heart data*. Status
  colours grade. They say *this needs attention*. A single element carries one or
  the other, never both, and never colour alone.
  [The two colour axes](../health/two-colour-axes.mdx) is the rule; a component
  that makes it hard to obey is a bug in the component.

  ### Check the escalation budget [#check-the-escalation-budget]

  Count the surfaces on the finished screen that are at *attention* or *urgent*.
  The budget is one urgent surface per screen and it is not negotiable. See
  [Alarm fatigue](../health/alarm-fatigue.mdx). If your choice puts you over
  budget, the fix is to demote, not to redesign the banner.
</Steps>

## The copy [#the-copy]

The decision you just made determines three strings, and they are the same three
every time:

* **The label** says what was measured, in the reader's words. Never what it
  means: "Resting heart rate", not "Heart health".
* **The unit** sits adjacent to the number, never in a legend, never only in a
  heading. See
  [Numbers, units & precision](../health/numbers-units-precision.mdx), which is
  canonical.
* **The time** says when it was measured, not when it was synced. A value with
  no time is a claim about the present that you cannot support.

<DoDont>
  <DoDont.Do>
    "Resting heart rate · 58 bpm · measured this morning". This gives what was
    measured, the unit beside the number, and when.
  </DoDont.Do>

  <DoDont.Dont>
    "Heart Health: 58 Normal". Three faults in five words: an interpretation
    used as the label, a number with no unit, and a banned word.
  </DoDont.Dont>
</DoDont>

"Normal" is banned system-wide. The reasoning is on
[Reference ranges](../health/reference-ranges.mdx): it means *typical for a
population* to a clinician and *fine, nothing to worry about* to everybody else,
and there is no context in a consumer app that disambiguates it.

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

* **No component computes a status.** If your chosen component is deciding
  whether a value is *watch* or *attention*, the clinical rule has leaked into
  the UI. Move it out.
* **Nothing is signalled by colour alone.** Status is a word, an icon and a
  colour, in that order of importance. Verify by rendering the screen in
  greyscale: see
  [Colour independence](../accessibility/colour-independence.mdx).
* **Nothing is signalled by motion alone.** Urgency never animates; see
  [Motion in health UI](../health/motion-in-health-ui.mdx).
* **A composite score discloses its derivation.** If you chose
  [ScoreDial](../components/score-dial.mdx), the screen has to be able to answer
  "what is this made of?" without leaving it.
* **Every choice has five data states.** Empty, loading, error, stale and partial
  are defined once in [Data states](../foundations/data-states.mdx). Deciding the
  component is not finished until you have decided what it renders in all five.
* **Touch targets clear 44pt with real separation** between adjacent targets:
  [Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).

## Variations [#variations]

**The datum is a set of answers to questions.** A symptom questionnaire, a mood
scale, a medication schedule. These now have components:
[Questionnaire](../components/questionnaire.mdx),
[SymptomPicker](../components/symptom-picker.mdx),
[ScaleInput](../components/scale-input.mdx),
[DoseTracker](../components/dose-tracker.mdx) and
[GoalRing](../components/goal-ring.mdx).

<Todo>
  This recipe's decision table stops at display and formatting. The input-shaped
  components above exist and install, but the rule for choosing between a
  questionnaire, a scale and a dose tracker is not written here yet. Read each
  component's own page from [Components](../components/index.mdx) until it is.
</Todo>

**The datum came from a device.** Then it carries provenance: which device, at
what accuracy, and under what conditions. The display has to be able to show it.
[SourceCitation](../components/source-citation.mdx) is the component for exactly
this, and it ships.

**The datum is a comparison against the reader's own history**, not against a
population range. That is a trend, not a result: go to
[Show a trend with an honest caption](./trend-with-a-caption.mdx), and note that
a personal baseline is a different claim from a reference range and must not be
drawn as one.

**The datum is an absence.** No reading today is a legitimate thing to display
and is not a zero. Use [EmptyState](../components/empty-state.mdx) with one clear
next action; see
[Empty and first use](../patterns/empty-and-first-use.mdx).

## Related [#related]

* **[Components](../components/index.mdx)** is the full matrix, filterable by
  category.
* **[The two colour axes](../health/two-colour-axes.mdx)** is the rule the
  fourth column of the table keeps referring to, in full.
* **[Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx)**
  covers ten prohibitions stated so you can test against them.
