MetricTile
A compact readout of one measurement. It carries the number, its unit and how recent it is, and it is sized to sit in a grid with several others.
Preview
metric-tile · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/metric-tile
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 { MetricTile } from "@/components/ui/metric-tile"<MetricTile
label="Example measurement"
value={14}
unit="steps"
precision={0}
measuredAt="2026-04-06T07:15:00+00:00"
now={now}
href="/results/example"
/>When to use it
Use it when
- A summary grid where several measurements are visible at once and the reader scans.
- A measurement the reader already understands, such as their resting heart rate.
- An entry point: a tile that opens the full result via href.
Do not use it when
- The reader is seeing this measurement for the first time and needs it explained.Use
result-cardinstead. - The value only makes sense against a reference range that will not fit.Use
range-barinstead. - A whole surface has no reading yet. One empty cell in a grid is still a tile.Use
empty-stateinstead. - You need to show how the value has moved. This draws no line.Use
trend-sparklineinstead. - The value is still loading and the grid would otherwise reflow.Use
skeletoninstead.
Clinical meaning
Asserts. With a reading, that this measurement had this value, in this unit, at
this time, so measuredAt is required beside one. An absence is not a reading of zero.
Never read as. A current reading, unless it is one. Past the product's boundary the tile mutes and says so in words, as Uncertainty and staleness requires.
Colour axis. Category, on the icon and the label only. Status appears as an embedded StatusPill, never the tile's background, so no element carries both (The two colour axes).
Thresholds. None. staleAfterHours has no default, because opsinjs holds no boundary
for any measurement. Omitted, there is no stale treatment. The tile derives no level:
status is the product's, under
Clinical status semantics. Pair attention or
urgent with an href, because nothing here enforces it.
Provenance and masking. There is no provenance prop and no mask, so do not route a
figure nobody measured through measuredAt.
Data provenance and
On-screen privacy name this component, and neither is met.
Anatomy
MetricTileThe root: an <a> with href, a container without. Carries neither axis.Controlled byhrefSurfaceOne level down, the card rung supplies the material.MetricTile.HeaderIcon and label on one row. Not a heading element.MetricTile.IconThe product's glyph, category-tinted and aria-hidden, only when passed.Controlled byiconMetricTile.LabelWhat was measured, in the reader's words. Carries data-category, and mutes when stale.Controlled bylabelMetricTile.AffordanceA trailing aria-hidden chevron, only with href.Controlled byhrefMetricTile.ReadingOne Value at display size, the loudest thing here. An absence drops to muted headline.Controlled byvalueMetricTile.TimeOne RelativeTime: event word, exact date, staleness words. Dropped when value is null.Controlled bymeasuredAtStatusPillThe product's level, embedded last, after the time line, and absent when no status was assigned.Controlled bystatus
Examples
A summary grid
Every tint is category. Exactly one tile carries a status, as a pill with the word in
it. The others say nothing about urgency.
metric-tile-a-summary-grid · base base · style base-lyraOpen under the product themeNo boundary was supplied
Two readings, three hours and five weeks old, with no staleAfterHours. Only the printed
date marks the older one.
metric-tile-no-boundary-supplied · base base · style base-lyraOpen under the product themeStates
| Data state | What MetricTile renders |
|---|---|
| Error | A measuredAt the tile cannot locate beside a reading is refused, and nothing renders. |
| Empty | value={null} keeps the label, drops the time line, and is not zero. |
| Stale | Past staleAfterHours the parts mute and the time line adds may be out of date. |
Content guidelines
Labels are two or three words in the reader's vocabulary: resting heart rate, not RHR. A caveat belongs on the surface the tile opens.
Do
"Resting heart rate", with the unit beside the number and the recency on the tile.
Don’t
"RHR" with a bare number: an unknown acronym, no unit and no date.
Accessibility
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author-run,
not an independent review, and clinical review is still pending. The rendered pass returned
zero axe violations, no target-size hits, no focus-visible failure and no reflow at 320px;
the source pass found no defect to fix.
Residual.
- A linked tile can trip WCAG 2.5.3 Label in Name because
Valuespeaks kilograms for a visible kg. The repair belongs inValueand is deferred; the steps demo does not trip. - Nothing enforces the JSDoc rule that
attentionandurgentpair with anhref. - The event word names when a reading happened, not how it was obtained; there is no provenance prop, so an estimate and a lab result sound alike.
Beyond the audit. Contrast as the tile composes it, prefers-contrast: more,
forced-colors, 200% text, print, and masking all stay unverified.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves to the tile, when it has an href | One stop for the whole tile, none without an href. |
| Shift+Tab | Moves backwards to the tile | DOM order is visual order. |
| Enter | Follows the link | Only when href is set. |
| Space | Scrolls the page | A link does not activate on Space. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| activity ink on activity surface | light | 88.5 | 9.61:1 | Pass |
| activity ink on activity surface | dark | -83.1 | 12.58:1 | Pass |
| activity line on activity surface | light | 67.6 | 4.42:1 | Pass |
| activity line on activity surface | dark | -69.0 | 9.93:1 | Pass |
| activity accent on activity surface | light | 44.1 | 2.34:1 | Below floor |
| activity accent on activity surface | dark | -53.3 | 7.26:1 | Pass |
| activity accent on the page | light | 46.1 | 2.41:1 | Below floor |
| activity accent on the page | dark | -54.6 | 8.22:1 | Pass |
| activity ink on the page | light | 90.5 | 9.90:1 | Pass |
| activity ink on the page | dark | -84.4 | 14.24:1 | Pass |
| activity line on the band | light | 66.1 | 4.32:1 | Pass |
| activity line on the band | dark | -67.4 | 8.60:1 | Pass |
| heart ink on heart surface | light | 89.1 | 10.51:1 | Pass |
| heart ink on heart surface | dark | -80.5 | 12.55:1 | Pass |
| heart line on heart surface | light | 70.4 | 5.05:1 | Pass |
| heart line on heart surface | dark | -64.5 | 9.46:1 | Pass |
| heart accent on heart surface | light | 60.3 | 3.64:1 | Pass |
| heart accent on heart surface | dark | -48.2 | 6.68:1 | Pass |
| heart accent on the page | light | 63.2 | 3.81:1 | Pass |
| heart accent on the page | dark | -49.5 | 7.27:1 | Pass |
| heart ink on the page | light | 92.0 | 10.97:1 | Pass |
| heart ink on the page | dark | -81.8 | 13.65:1 | Pass |
| heart line on the band | light | 69.9 | 5.01:1 | Pass |
| heart line on the band | dark | -62.8 | 7.88:1 | Pass |
| labs ink on labs surface | light | 88.8 | 9.78:1 | Pass |
| labs ink on labs surface | dark | -83.1 | 12.69:1 | Pass |
| labs line on labs surface | light | 67.9 | 4.51:1 | Pass |
| labs line on labs surface | dark | -68.4 | 9.90:1 | Pass |
| labs accent on labs surface | light | 59.3 | 3.49:1 | Pass |
| labs accent on labs surface | dark | -52.7 | 7.21:1 | Pass |
| labs accent on the page | light | 61.5 | 3.60:1 | Pass |
| labs accent on the page | dark | -53.9 | 8.07:1 | Pass |
| labs ink on the page | light | 90.9 | 10.09:1 | Pass |
| labs ink on the page | dark | -84.3 | 14.22:1 | Pass |
| labs line on the band | light | 66.5 | 4.42:1 | Pass |
| labs line on the band | dark | -66.7 | 8.49:1 | Pass |
| mind ink on mind surface | light | 89.9 | 10.36:1 | Pass |
| mind ink on mind surface | dark | -81.3 | 12.56:1 | Pass |
| mind line on mind surface | light | 70.7 | 4.94:1 | Pass |
| mind line on mind surface | dark | -65.4 | 9.54:1 | Pass |
| mind accent on mind surface | light | 60.0 | 3.55:1 | Pass |
| mind accent on mind surface | dark | -49.1 | 6.77:1 | Pass |
| mind accent on the page | light | 62.8 | 3.70:1 | Pass |
| mind accent on the page | dark | -50.0 | 7.42:1 | Pass |
| mind ink on the page | light | 92.7 | 10.80:1 | Pass |
| mind ink on the page | dark | -82.2 | 13.77:1 | Pass |
| mind line on the band | light | 70.0 | 4.89:1 | Pass |
| mind line on the band | dark | -63.4 | 8.00:1 | Pass |
| nutrition ink on nutrition surface | light | 89.3 | 10.06:1 | Pass |
| nutrition ink on nutrition surface | dark | -81.9 | 12.55:1 | Pass |
| nutrition line on nutrition surface | light | 69.7 | 4.78:1 | Pass |
| nutrition line on nutrition surface | dark | -66.7 | 9.66:1 | Pass |
| nutrition accent on nutrition surface | light | 39.2 | 2.11:1 | Below floor |
| nutrition accent on nutrition surface | dark | -50.2 | 6.88:1 | Pass |
| nutrition accent on the page | light | 41.8 | 2.19:1 | Below floor |
| nutrition accent on the page | dark | -51.3 | 7.63:1 | Pass |
| nutrition ink on the page | light | 91.9 | 10.44:1 | Pass |
| nutrition ink on the page | dark | -82.9 | 13.91:1 | Pass |
| nutrition line on the band | light | 68.8 | 4.71:1 | Pass |
| nutrition line on the band | dark | -64.8 | 8.19:1 | Pass |
| sleep ink on sleep surface | light | 89.6 | 10.19:1 | Pass |
| sleep ink on sleep surface | dark | -81.9 | 12.60:1 | Pass |
| sleep line on sleep surface | light | 70.0 | 4.80:1 | Pass |
| sleep line on sleep surface | dark | -66.4 | 9.65:1 | Pass |
| sleep accent on sleep surface | light | 64.6 | 4.05:1 | Pass |
| sleep accent on sleep surface | dark | -50.4 | 6.94:1 | Pass |
| sleep accent on the page | light | 67.3 | 4.22:1 | Pass |
| sleep accent on the page | dark | -51.4 | 7.67:1 | Pass |
| sleep ink on the page | light | 92.3 | 10.60:1 | Pass |
| sleep ink on the page | dark | -82.9 | 13.92:1 | Pass |
| sleep line on the band | light | 69.1 | 4.75:1 | Pass |
| sleep line on the band | dark | -64.5 | 8.16: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 |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
| activity ink on the card material | light | 93.6 | 10.34:1 | Pass |
| activity ink on the card material | dark | -83.5 | 12.91:1 | Pass |
| heart ink on the card material | light | 95.1 | 11.47:1 | Pass |
| heart ink on the card material | dark | -80.9 | 12.38:1 | Pass |
| labs ink on the card material | light | 94.0 | 10.54:1 | Pass |
| labs ink on the card material | dark | -83.5 | 12.90:1 | Pass |
| mind ink on the card material | light | 95.7 | 11.28:1 | Pass |
| mind ink on the card material | dark | -81.4 | 12.49:1 | Pass |
| nutrition ink on the card material | light | 95.0 | 10.91:1 | Pass |
| nutrition ink on the card material | dark | -82.0 | 12.61:1 | Pass |
| sleep ink on the card material | light | 95.4 | 11.08:1 | Pass |
| sleep ink on the card material | dark | -82.1 | 12.62: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 part; icon, affordance and time only when rendered | metric-tile, metric-tile-header, metric-tile-icon, metric-tile-label, metric-tile-affordance, metric-tile-reading, metric-tile-time |
| data-category | On the icon and the label only, when a known category is passed | sleep | heart | activity | nutrition | mind | labs |
| data-status | On the embedded StatusPill only, when a status is set. The tile stamps none | steady | watch | attention | urgent |
| data-opsinjs-value | On the embedded Value, always | The unrounded number, or an empty string with no reading |
API reference
Prop
Type
Generated from MetricTileProps in registry/bases/base/metric-tile.tsx.
A measuredAt this component cannot locate beside a reading is refused: nothing renders,
and development reports it once. label, value, measuredAt, now and precision are
required with no default. value is number | null, so a compound reading takes two
tiles. Read now once per screen and pass it to every tile.
Related
- ResultCard is the same data with room to explain itself.
- Value is the formatting primitive inside the tile.
- Card is a different component, not a bigger tile.
- StatusPill is the level inside a tile, never its background.
- TrendSparkline is not embedded, because a line needs a caption.
TrendSparkline
A small line showing how one measurement has moved over time, with a caption that says what changed and leaves the verdict to you.
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.