---
title: "Build a health metric card"
description: "One measurement on a dashboard, with its unit, its age, its category and an honest answer in all five data states."
url: "https://opsinjs.pensievelabs.org/recipes/health-metric-card"
source: "https://opsinjs.pensievelabs.org/recipes/health-metric-card.md"
section: "Recipes"
kind: "recipe"
evidence: "opinion"
reviewed: "2026-09-20"
reviewer: "engineering"
implements: ["metric-tile", "button", "card", "relative-time", "result-card", "score-dial", "status-pill", "surface", "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]

You are building the surface a person opens first: a grid of tiles, each carrying
one number. Resting heart rate. Hours slept. Steps. Blood glucose. The tile has
about one second of the reader's attention, and in that second it has to say what
was measured, what the number is, what unit it is in, and how old it is.

Most health dashboards get the first three right and drop the fourth. A tile that
shows `58 bpm` with no timestamp is making a claim about right now using a
measurement from Tuesday. That is the defect this recipe exists to prevent.

The card you finish with is deliberately modest: it identifies and it reports. It
does not interpret. Interpretation asks whether the number is good and whether
you should do something. That is a [ResultCard](../components/result-card.mdx)
or a [CareCard](../components/care-card.mdx), and it belongs on the detail
screen the tile opens.

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

| Part                   | Component                                                             | Status                           | What it does here                                                  |
| ---------------------- | --------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------ |
| The tile               | [MetricTile](../components/metric-tile.mdx)                           | <StatusBadge status="shipped" /> | The whole target: label, value, unit, time, optional status.       |
| The number             | [Value](../components/value.mdx)                                      | <StatusBadge status="shipped" /> | Precision, unit adjacency, locale, false-precision guard.          |
| The age                | [RelativeTime](../components/relative-time.mdx)                       | <StatusBadge status="shipped" /> | "2 hours ago" with an absolute fallback and a staleness threshold. |
| The status, if any     | [StatusPill](../components/status-pill.mdx)                           | <StatusBadge status="shipped" /> | Word, icon and colour together. Colour never carries it alone.     |
| The surface underneath | [Surface](../components/surface.mdx) · [Card](../components/card.mdx) | <StatusBadge status="shipped" /> | The material rung and the bounded content group.                   |
| Loading                | [Skeleton](../components/skeleton.mdx)                                | <StatusBadge status="shipped" /> | Preserves layout without implying a value.                         |
| Nothing yet            | [EmptyState](../components/empty-state.mdx)                           | <StatusBadge status="shipped" /> | An absence, with one next action. Not a zero.                      |

Tokens you will touch, by family. The leaf names and their measured contrast are
in the generated reference, never copied into prose:

| Family               | Where it applies                                        | Reference                                                                                                |
| -------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `--opsin-category-*` | The tile's identity: accent, surface tint, ink          | [Category identity](../health/category-identity.mdx) · [Colour tokens](../foundations/colour/tokens.mdx) |
| `--opsin-status-*`   | The status pill only, and only if a status was assigned | [Clinical status semantics](../health/clinical-status-semantics.mdx)                                     |
| Material rung        | The card's translucency and its opaque fallback         | [Choosing a layer](../foundations/materials/choosing-a-layer.mdx)                                        |
| Type scale           | Label, value and caption sizes, which must survive 200% | [Type scale](../foundations/typography/type-scale.mdx)                                                   |
| Space scale          | Padding and the separation between adjacent tiles       | [Density and touch](../foundations/space/density-and-touch.mdx)                                          |

## Build it [#build-it]

<Steps>
  ### Write the label first [#write-the-label-first]

  The label is the hardest part of the tile and the part usually written last.
  Rules: name what was measured, not what it means; use the reader's words; do not
  abbreviate a unit into the label to save space.

  "Resting heart rate" is a label. "Heart health" is an interpretation. "RHR" is
  neither. It is a note to yourself. If the honest label needs a clinical word,
  wrap that word in a [Term](../components/term.mdx) rather than dropping the word.

  ### Assign the category, and only the category [#assign-the-category-and-only-the-category]

  Every tile belongs to exactly one of the six categories. The category colour is
  an identity: it tells the reader *this tile is about your sleep*, so a grid of
  tiles is scannable without reading every label.

  A category colour must never encode how the number is doing. That is the
  never-mix rule in [The two colour axes](../health/two-colour-axes.mdx), and the
  tile is where it is most often broken, because a coloured tile that looks
  "healthy" is an easy thing to ship by accident.

  ### Format the number [#format-the-number]

  Delegate to [Value](../components/value.mdx). Three decisions it needs from you:

  * **Precision.** How many digits are meaningful for this measurement. That is
    not how many the sensor emits. Rendering `58.4213 bpm` is a false claim about
    accuracy.
  * **The unit, adjacent to the number.** Not in the label, not in a legend, not in
    a tooltip. The rules are canonical in
    [Numbers, units & precision](../health/numbers-units-precision.mdx).
  * **Which unit system.** mmol/L or mg/dL, kg or st, °C or °F. Convert once, at the
    edge of your system, and store the canonical form. See
    [Unit systems](../health/unit-systems.mdx). Converting in the view layer is how
    two tiles end up disagreeing.

  ### Stamp it with the measurement time [#stamp-it-with-the-measurement-time]

  Use [RelativeTime](../components/relative-time.mdx), with the *measurement* time,
  not the sync time. Those differ, and only one of them is a fact about the person.

  Set a staleness threshold per metric, because staleness is not a constant: a
  resting heart rate from this morning is current, a blood glucose reading from this
  morning is history, and a weight from last month may be perfectly fine. Past the
  threshold the tile is in the **stale** data state and has to say so. It does not
  quietly keep displaying the number as though it were now.

  ### Decide whether a status belongs on this tile at all [#decide-whether-a-status-belongs-on-this-tile-at-all]

  The default answer is no. A tile is a glance, and a glance is the worst place to
  deliver a clinical judgement, because there is no room for the qualification that
  judgement needs.

  Add a [StatusPill](../components/status-pill.mdx) only when all three are true: a
  rule your product owns assigned the status; the reader can act on it from here;
  and the screen is still inside its escalation budget
  ([Alarm fatigue](../health/alarm-fatigue.mdx)). Otherwise the status lives on the
  detail screen, where the sentence explaining it can also live.

  ### Give the tile somewhere to go [#give-the-tile-somewhere-to-go]

  A tile that cannot be opened is a dead end with a number in it. The whole tile is
  one target with one accessible name, not a card containing three separate small
  targets. It opens the detail surface where the range, the history and the
  meaning live.

  ### Decide all five data states before you ship one [#decide-all-five-data-states-before-you-ship-one]

  Empty, loading, error, stale and partial, defined once in
  [Data states](../foundations/data-states.mdx). Write down what the tile renders in
  each. The two that get skipped are **stale** and **partial**, and they are the two
  that mislead: a stale value shown as current is a false claim, and a partial value
  shown as complete ("2,000 steps" when the phone was in a drawer until noon) is a
  worse one.
</Steps>

## The copy [#the-copy]

Three strings and a caption. The sentence pattern is fixed so a grid of tiles
reads as one system rather than as eight authors.

* **Label** says what was measured, sentence case, no colon.
* **Value + unit** sit adjacent, rendered by [Value](../components/value.mdx).
* **Time** is "Measured 2 hours ago", not "Updated 2 hours ago". Updated is
  about your database; measured is about the person.
* **Status word, if present** is the word from the status vocabulary, never a
  synonym you invented. Wording per level is in
  [Writing status and alerts](../content/writing-status-and-alerts.mdx).

<DoDont>
  <DoDont.Do>
    "Resting heart rate · 58 bpm · measured 2 hours ago". If stale, it reads
    "last measured 6 days ago" with the value visibly de-emphasised.
  </DoDont.Do>

  <DoDont.Dont>
    "Heart: 58 · Normal · Updated just now". The problems are an interpretation
    as a label, no unit, a banned word, and a sync time standing in for a
    measurement time.
  </DoDont.Dont>
</DoDont>

Banned in a tile: *normal*, *abnormal*, *good*, *bad*, *healthy*, *unhealthy*,
*failed*, *out of range* used as a verdict. The reasoning for the first is in
[Reference ranges](../health/reference-ranges.mdx); the rest fail for the same
reason, which is that a tile has no room to say what the word is relative to.

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

**Safety**

* The tile asserts nothing beyond "this was measured, then". If a reader could
  reasonably read the tile as a verdict, the tile is wrong.
* No category colour changes with the value. Take a screenshot at a low value and
  a high one and diff them: the only permitted difference is the number, the
  time, and the status pill if there is one.
* The staleness threshold is per metric and is written down somewhere a reviewer
  can find. An undocumented threshold is an undocumented clinical decision.
* A partial value is labelled as partial. Silence is a claim.

**Accessibility**

* The whole tile is one target, at least 44pt in both dimensions, with real
  separation from its neighbours. See
  [Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
* Its accessible name reads in the order label, value with unit, time, status
  word. A screen-reader user must not have to reconstruct the tile from four
  unrelated fragments.
* Status survives greyscale and every simulated colour-vision deficiency, because
  the word and the icon carry it. See
  [Colour independence](../accessibility/colour-independence.mdx).
* At 200% text the tile reflows and nothing truncates the number or its unit; the
  unit never wraps away from the number.
  [Dynamic type](../foundations/typography/dynamic-type.mdx) is the contract.
* Under `prefers-reduced-transparency` the card falls back to its opaque rung and
  the contrast floor still holds.
  [Reduced transparency](../foundations/materials/reduced-transparency.mdx)
  specifies that fallback.
* Nothing about the tile animates to convey meaning. A value that counts up is
  decoration; under `prefers-reduced-motion` it is simply the final value.

## Variations [#variations]

**The metric has no unit.** Steps, sessions, glasses of water. The unit slot
becomes the noun ("8,412 steps") and the label must not repeat it.

**The metric is a duration.** Sleep, exercise. Format as hours and minutes, never
as a decimal. Write "7h 20m", not "7.33 hours". Decimal hours are precise and
unreadable.

**The metric has a personal goal.** A goal is not a reference range and must not
be drawn as one: a range is about a population, a goal is about this person's
intention. [GoalRing](../components/goal-ring.mdx) now ships for goal
progress, and it is drawn so that it cannot be read as a reference range.

**The metric is a composite.** A sleep score, a readiness index. That is a
[ScoreDial](../components/score-dial.mdx), and it carries an extra obligation:
the reader must be able to find out what it is made of without leaving the
product.

**Several metrics in one tile.** Systolic and diastolic blood pressure are one
measurement with two numbers, not two tiles. Keep them in one tile, with one
label, one time and the conventional `120/80` form. Splitting them invites the
reader to read one without the other.

## Related [#related]

* **[Show a value against a reference range](./value-against-a-range.mdx)** is
  the detail surface this tile opens, and where interpretation is allowed to
  live.
* **[MetricTile](../components/metric-tile.mdx)** is the component
  specification, including the parts, the proposed API and the accessibility
  bar it must clear.
* **[Data states](../foundations/data-states.mdx)** has the five states, defined
  once, that every data surface in the system shares.
