---
title: "ResultCard"
description: "One test result, shown in full. What was measured, the number, where it sits against its range, what that means in plain English, and what to do next."
url: "https://opsinjs.pensievelabs.org/components/result-card"
source: "https://opsinjs.pensievelabs.org/components/result-card.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["results", "lab result", "test result", "report card", "blood pressure", "a1c"]
governedBy: ["two-colour-axes", "reference-ranges", "numbers-units-precision", "uncertainty-and-staleness", "clinical-status-semantics", "data-provenance-and-device-accuracy", "delivering-difficult-results", "on-screen-privacy"]
usedIn: ["choose-a-component", "health-metric-card", "result-disclosure", "results-screen", "sharing-with-a-clinician", "value-against-a-range"]
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="result-card"
  status="shipped"
  questions="[
  &#x22;The accessible name is the title alone, not title plus reading, and nobody has listened to whether that is enough.&#x22;,
  &#x22;No contrast pair, hit area, printout or screen-reader pass has been measured on this card.&#x22;,
  &#x22;The not-medical-advice note is not on this card, and whether products will place it is open.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="result-card" />

## Installation [#installation]

<ComponentInstall name="result-card" unbuilt="false" importPath="@/components/ui/result-card" registryDependencies="[&#x22;status-pill&#x22;, &#x22;value&#x22;, &#x22;range-bar&#x22;, &#x22;relative-time&#x22;, &#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { ResultCard } from "@/components/ui/result-card"
import { EXAMPLE_SOURCE } from "@/lib/opsinjs"
```

```tsx
<ResultCard
  title="Example measurement"
  value={14}
  unit="mg/dL"
  precision={0}
  measuredAt="2026-03-14T08:12:00+00:00"
  now="2026-03-14T11:12:00+00:00"
  range={{ low: 10, high: 20, source: EXAMPLE_SOURCE }}
  status="steady"
  category="labs"
  meaning="Two or three short sentences, in the second person, written by the product."
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A single result with room for its range, meaning and next step, behind authentication.&#x22;,
  &#x22;A result seen for the first time, where the explanation matters as much as the number.&#x22;,
]"
  avoid="[
  { case: &#x22;Several readings side by side in a dashboard grid.&#x22;, instead: &#x22;metric-tile&#x22; },
  { case: &#x22;The number has no range, no status and nothing to explain.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;You only need to show where a value sits within its range.&#x22;, instead: &#x22;range-bar&#x22; },
  { case: &#x22;The main message is an instruction rather than a measurement.&#x22;, instead: &#x22;care-card&#x22; },
  { case: &#x22;Something has changed and must interrupt the reader now.&#x22;, instead: &#x22;alert-banner&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** A measurement was taken, at a time, in a unit. Where supplied, it
also asserts a stated range and the level the product assigned.
[Every number](../health/numbers-units-precision.mdx) goes through Value.

**Never read as.** A diagnosis, a summary of the reader's health, or a clinician's advice. A next step is the
product's, and who is speaking lives in the words you write in `meaning` and in
an action's label. A result that may distress needs
[Delivering difficult results](../health/delivering-difficult-results.mdx)
around it.

**Colour axis.** Both, on different elements, as
[the two colour axes](../health/two-colour-axes.mdx) requires: status reaches
only the [StatusPill](./status-pill.mdx), category only the title, and no prop
tints the surface.

**Thresholds.** The product's. No default [range](../health/reference-ranges.mdx),
no [status](../health/clinical-status-semantics.mdx) derivation and no
[staleness](../health/uncertainty-and-staleness.mdx) boundary exist here.
`staleAfterHours` passes through to [RelativeTime](./relative-time.mdx).
A reading you cannot vouch for must not drive `attention` or `urgent`.

**Provenance and privacy.** `provenance` is free text with no
[class](../health/data-provenance-and-device-accuracy.mdx), so keeping an
estimated or self-reported value free of a `status` is your rule. There is no
masking affordance; [On-screen privacy](../health/on-screen-privacy.mdx) is
built around the card.

## Anatomy [#anatomy]

<Anatomy
  name="result-card"
  parts="[
  {
    name: &#x22;ResultCard&#x22;,
    describes: &#x22;An article named by its title. Neutral surface; layout through className.&#x22;,
  },
  {
    name: &#x22;ResultCard.Header&#x22;,
    describes: &#x22;Title, time and level. On a phone the level wraps beneath the time.&#x22;,
  },
  {
    name: &#x22;ResultCard.Title&#x22;,
    describes: &#x22;A real heading at titleLevel. The only element the category axis touches.&#x22;,
    prop: &#x22;category&#x22;,
  },
  {
    name: &#x22;ResultCard.Time&#x22;,
    describes: &#x22;Wraps RelativeTime with the absolute date. Carries the staleness words past a supplied boundary.&#x22;,
    prop: &#x22;staleAfterHours&#x22;,
  },
  {
    name: &#x22;StatusPill&#x22;,
    describes: &#x22;The only status-coloured element. Rendered only when the product assigned a level.&#x22;,
    prop: &#x22;status&#x22;,
  },
  {
    name: &#x22;ResultCard.Reading&#x22;,
    describes: &#x22;The number at display size through Value, one per segment when compound.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;RangeBar&#x22;,
    describes: &#x22;Drawn only with a range, a unit and a single value. Given no status.&#x22;,
    prop: &#x22;range&#x22;,
  },
  {
    name: &#x22;ResultCard.Meaning&#x22;,
    describes: &#x22;The plain-English paragraph. When absent, the sentence saying so.&#x22;,
    prop: &#x22;meaning&#x22;,
  },
  {
    name: &#x22;ResultCard.Actions&#x22;,
    describes: &#x22;At most two. An href renders a Link, onSelect a Button; the recommended one is bordered.&#x22;,
    prop: &#x22;actions&#x22;,
  },
  {
    name: &#x22;ResultCard.Footnote&#x22;,
    describes: &#x22;Provenance only, when supplied. No default disclaimer.&#x22;,
    prop: &#x22;provenance&#x22;,
  },
]"
/>

## Examples [#examples]

### A reading made of two numbers [#a-reading-made-of-two-numbers]

`segments` carries a compound reading, each part a real [Value](./value.mdx).
The unit prints once, is spoken after every part, and no bar is drawn.

<ComponentPreview name="result-card-a-compound-reading" kind="example" align="start" />

### The smallest card, and the sentence it will not leave out [#the-smallest-card-and-the-sentence-it-will-not-leave-out]

No `range`, so no bar; no `status`, so no pill; no `provenance`, so no footnote.
An absent `meaning` still renders as a sentence saying there is none.

<ComponentPreview name="result-card-no-explanation-supplied" kind="example" align="start" />

### The same result at two ages [#the-same-result-at-two-ages]

Two cards differing only in `measuredAt`, with the same level, because age lowers
what a reading is worth and does not raise the alarm.

<ComponentPreview name="result-card-an-old-reading" kind="example" align="start" />

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA.** A source pass and a rendered browser pass were both
clean: zero axe violations, no target below the 44px floor, no focus-visible gap. It
found nothing to fix, is author-run rather than an independent review, and leaves
clinical review pending.

**What it confirmed.**

* The two colour axes stay separate: category tint reaches only the title, clinical
  status only the [StatusPill](./status-pill.mdx), which carries a word and icon.
* Units speak through [Value](./value.mdx) after each part of a compound reading, the
  title is a real heading at the caller's level, and the card reflows at 320px and
  200% without clipping.

**Documented, by design.**

* The root `<article>` takes `aria-label` from the title, not `aria-labelledby`. The
  string matches the visible heading and the region is non-interactive, so there is no
  name mismatch; a visible-heading reference is a future enhancement.
* `className` merges onto the root, so a caller who passes `truncate` can clip a
  reading. The component truncates nothing; the JSDoc warns of it.

**Not checked by any gate.** Print, `forced-colors` and `prefers-contrast`.

<KeyboardTable
  name="result-card"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves to the first action, where the card has one&#x22;,
    notes: &#x22;The card itself is not a tab stop.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves backwards through the same stops&#x22;,
    notes: &#x22;Reverse order matches visual order.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the focused action&#x22;,
    notes: &#x22;Platform behaviour. Nothing is destructive.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates a focused button action&#x22;,
    notes: &#x22;On an onSelect action only.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;Not dismissible. Inside a Sheet or Dialog, Escape is theirs.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;The bar is role=\&#x22;img\&#x22;, not a slider.&#x22;,
  },
]"
/>

<ContrastReport component="result-card" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="result-card"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On every part, always&#x22;,
    value: &#x22;result-card, result-card-header, -title, -time, -reading, -meaning, -actions, -footnote; result-card-action on a link action, button on an onSelect action&#x22;,
  },
  {
    attribute: &#x22;data-status&#x22;,
    condition: &#x22;On the embedded StatusPill only, with a status&#x22;,
    value: &#x22;steady | watch | attention | urgent&#x22;,
  },
  {
    attribute: &#x22;data-category&#x22;,
    condition: &#x22;On ResultCard.Title, for one of the six categories&#x22;,
    value: &#x22;sleep | heart | activity | nutrition | mind | labs&#x22;,
  },
  {
    attribute: &#x22;data-opsinjs-value&#x22;,
    condition: &#x22;On every Value, including a drawn RangeBar's. De-duplicate before scraping.&#x22;,
    value: &#x22;The unrounded number, or an empty string with no reading&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="ResultCardProps" />

`now` and `precision` are required: the card never reads the clock, so pass one
`now` to every card on a screen. `value` is `number | null`, never a string; a
pair is `segments`. A third action, a second `recommended` and a `titleLevel`
outside 2 to 6 each warn once and fall back.

## Related [#related]

* [MetricTile](./metric-tile.mdx) is the same data with no room to explain itself.
* [Card](./card.mdx) is a neutral box with no clinical contract.
* [CareCard](./care-card.mdx) is a card whose subject is an instruction, not a measurement.
* [AlertBanner](./alert-banner.mdx) is for something that must interrupt.
* [DisclaimerNote](./disclaimer-note.mdx) is where the not-medical-advice note lives.
