---
title: "MetricTile"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/metric-tile"
source: "https://opsinjs.pensievelabs.org/components/metric-tile.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["stat", "kpi", "tile", "vitals", "resting heart rate", "spo2", "summary tile", "steps"]
governedBy: ["category-identity", "clinical-interaction-guidelines", "clinical-status-semantics", "data-provenance-and-device-accuracy", "numbers-units-precision", "on-screen-privacy", "reference-ranges", "trends-and-change", "two-colour-axes", "uncertainty-and-staleness", "unit-systems"]
usedIn: ["alert-escalation", "choose-a-component", "daily-log-screen", "empty-and-first-use", "health-metric-card", "offline-and-stale-data", "results-screen", "trend-review", "trends-screen"]
implemented: true
---

> 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.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="metric-tile"
  status="shipped"
  questions="[
  &#x22;There is no mask, no reveal and no sensitivity prop, and Value owns the digits and has none either.&#x22;,
  &#x22;event names when a reading happened, never where it came from, so a device estimate and a laboratory result sound alike, with no provenance prop.&#x22;,
  &#x22;The instant rule and the staleness verdict are computed here and again inside RelativeTime, and only a reader keeps the two in step.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="metric-tile" />

## Installation [#installation]

<ComponentInstall name="metric-tile" unbuilt="false" importPath="@/components/ui/metric-tile" registryDependencies="[&#x22;surface&#x22;, &#x22;value&#x22;, &#x22;status-pill&#x22;, &#x22;relative-time&#x22;]" />

## Usage [#usage]

```tsx
import { MetricTile } from "@/components/ui/metric-tile"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A summary grid where several measurements are visible at once and the reader scans.&#x22;,
  &#x22;A measurement the reader already understands, such as their resting heart rate.&#x22;,
  &#x22;An entry point: a tile that opens the full result via href.&#x22;,
]"
  avoid="[
  { case: &#x22;The reader is seeing this measurement for the first time and needs it explained.&#x22;, instead: &#x22;result-card&#x22; },
  { case: &#x22;The value only makes sense against a reference range that will not fit.&#x22;, instead: &#x22;range-bar&#x22; },
  { case: &#x22;A whole surface has no reading yet. One empty cell in a grid is still a tile.&#x22;, instead: &#x22;empty-state&#x22; },
  { case: &#x22;You need to show how the value has moved. This draws no line.&#x22;, instead: &#x22;trend-sparkline&#x22; },
  { case: &#x22;The value is still loading and the grid would otherwise reflow.&#x22;, instead: &#x22;skeleton&#x22; },
]"
/>

## Clinical meaning [#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](../health/uncertainty-and-staleness.mdx) requires.

**Colour axis.** Category, on the icon and the label only. Status appears as an embedded
[StatusPill](./status-pill.mdx), never the tile's background, so no element carries
both ([The two colour axes](../health/two-colour-axes.mdx)).

**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](../health/clinical-status-semantics.mdx). 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](../health/data-provenance-and-device-accuracy.mdx) and
[On-screen privacy](../health/on-screen-privacy.mdx) name this component, and neither is met.

## Anatomy [#anatomy]

<Anatomy
  name="metric-tile"
  parts="[
  {
    name: &#x22;MetricTile&#x22;,
    describes: &#x22;The root: an <a> with href, a container without. Carries neither axis.&#x22;,
    prop: &#x22;href&#x22;,
  },
  {
    name: &#x22;Surface&#x22;,
    describes: &#x22;One level down, the card rung supplies the material.&#x22;,
  },
  {
    name: &#x22;MetricTile.Header&#x22;,
    describes: &#x22;Icon and label on one row. Not a heading element.&#x22;,
  },
  {
    name: &#x22;MetricTile.Icon&#x22;,
    describes: &#x22;The product's glyph, category-tinted and aria-hidden, only when passed.&#x22;,
    prop: &#x22;icon&#x22;,
  },
  {
    name: &#x22;MetricTile.Label&#x22;,
    describes: &#x22;What was measured, in the reader's words. Carries data-category, and mutes when stale.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;MetricTile.Affordance&#x22;,
    describes: &#x22;A trailing aria-hidden chevron, only with href.&#x22;,
    prop: &#x22;href&#x22;,
  },
  {
    name: &#x22;MetricTile.Reading&#x22;,
    describes: &#x22;One Value at display size, the loudest thing here. An absence drops to muted headline.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;MetricTile.Time&#x22;,
    describes: &#x22;One RelativeTime: event word, exact date, staleness words. Dropped when value is null.&#x22;,
    prop: &#x22;measuredAt&#x22;,
  },
  {
    name: &#x22;StatusPill&#x22;,
    describes: &#x22;The product's level, embedded last, after the time line, and absent when no status was assigned.&#x22;,
    prop: &#x22;status&#x22;,
  },
]"
/>

## Examples [#examples]

### A summary grid [#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.

<ComponentPreview name="metric-tile-a-summary-grid" kind="example" align="start" />

### No boundary was supplied [#no-boundary-was-supplied]

Two readings, three hours and five weeks old, with no `staleAfterHours`. Only the printed
date marks the older one.

<ComponentPreview name="metric-tile-no-boundary-supplied" kind="example" align="start" />

## States [#states]

| 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 [#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.

<DoDont>
  <DoDont.Do>
    **"Resting heart rate"**, with the unit beside the number and the recency on the tile.
  </DoDont.Do>

  <DoDont.Dont>
    **"RHR"** with a bare number: an unknown acronym, no unit and no date.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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 `Value` speaks *kilograms* for a
  visible *kg*. The repair belongs in `Value` and is deferred; the *steps* demo does not trip.
* Nothing enforces the JSDoc rule that `attention` and `urgent` pair with an `href`.
* 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.

<KeyboardTable
  name="metric-tile"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the tile, when it has an href&#x22;,
    notes: &#x22;One stop for the whole tile, none without an href.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves backwards to the tile&#x22;,
    notes: &#x22;DOM order is visual order.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Follows the link&#x22;,
    notes: &#x22;Only when href is set.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Scrolls the page&#x22;,
    notes: &#x22;A link does not activate on Space.&#x22;,
  },
]"
/>

<ContrastReport component="metric-tile" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="metric-tile"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the root and every part; icon, affordance and time only when rendered&#x22;,
    value: &#x22;metric-tile, metric-tile-header, metric-tile-icon, metric-tile-label, metric-tile-affordance, metric-tile-reading, metric-tile-time&#x22;,
  },
  {
    attribute: &#x22;data-category&#x22;,
    condition: &#x22;On the icon and the label only, when a known category is passed&#x22;,
    value: &#x22;sleep | heart | activity | nutrition | mind | labs&#x22;,
  },
  {
    attribute: &#x22;data-status&#x22;,
    condition: &#x22;On the embedded StatusPill only, when a status is set. The tile stamps none&#x22;,
    value: &#x22;steady | watch | attention | urgent&#x22;,
  },
  {
    attribute: &#x22;data-opsinjs-value&#x22;,
    condition: &#x22;On the embedded Value, always&#x22;,
    value: &#x22;The unrounded number, or an empty string with no reading&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="MetricTileProps" />

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 [#related]

* [ResultCard](./result-card.mdx) is the same data with room to explain itself.
* [Value](./value.mdx) is the formatting primitive inside the tile.
* [Card](./card.mdx) is a different component, not a bigger tile.
* [StatusPill](./status-pill.mdx) is the level inside a tile, never its background.
* [TrendSparkline](./trend-sparkline.mdx) is not embedded, because a line needs a caption.
