opsinjs

Build a health metric card

One measurement on a dashboard, with its unit, its age, its category and an honest answer in all five data states.

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 or a CareCard, and it belongs on the detail screen the tile opens.

What you need

PartComponentStatusWhat it does here
The tileMetricTileShippedThe whole target: label, value, unit, time, optional status.
The numberValueShippedPrecision, unit adjacency, locale, false-precision guard.
The ageRelativeTimeShipped"2 hours ago" with an absolute fallback and a staleness threshold.
The status, if anyStatusPillShippedWord, icon and colour together. Colour never carries it alone.
The surface underneathSurface · CardShippedThe material rung and the bounded content group.
LoadingSkeletonShippedPreserves layout without implying a value.
Nothing yetEmptyStateShippedAn 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:

FamilyWhere it appliesReference
--opsin-category-*The tile's identity: accent, surface tint, inkCategory identity · Colour tokens
--opsin-status-*The status pill only, and only if a status was assignedClinical status semantics
Material rungThe card's translucency and its opaque fallbackChoosing a layer
Type scaleLabel, value and caption sizes, which must survive 200%Type scale
Space scalePadding and the separation between adjacent tilesDensity and touch

Build it

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 rather than dropping the word.

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

Delegate to Value. 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.
  • 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. Converting in the view layer is how two tiles end up disagreeing.

Stamp it with the measurement time

Use RelativeTime, 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

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 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). Otherwise the status lives on the detail screen, where the sentence explaining it can also live.

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

Empty, loading, error, stale and partial, defined once in Data states. 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.

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.
  • 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.

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.

Don’t

"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.

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

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.
  • 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.
  • At 200% text the tile reflows and nothing truncates the number or its unit; the unit never wraps away from the number. Dynamic type is the contract.
  • Under prefers-reduced-transparency the card falls back to its opaque rung and the contrast floor still holds. Reduced transparency 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

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 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, 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.

  • Show a value against a reference range is the detail surface this tile opens, and where interpretation is allowed to live.
  • MetricTile is the component specification, including the parts, the proposed API and the accessibility bar it must clear.
  • Data states has the five states, defined once, that every data surface in the system shares.

On this page