---
title: "RelativeTime"
description: "When something happened, written the way people say it. The exact date and time stay in reach, and explicit words appear once the staleness boundary is passed."
url: "https://opsinjs.pensievelabs.org/components/relative-time"
source: "https://opsinjs.pensievelabs.org/components/relative-time.md"
section: "Components"
status: "shipped"
kind: "component"
category: "health-formatting"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["ago", "timestamp", "last updated", "freshness", "staleness", "time ago", "date display"]
governedBy: ["uncertainty-and-staleness", "numbers-units-precision"]
usedIn: ["choose-a-component", "daily-log-screen", "daily-logging", "diabetes-medicines-app", "health-metric-card", "offline-and-stale-data", "results-screen", "sharing-with-a-clinician", "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="relative-time"
  status="shipped"
  questions="[
  &#x22;The component never ticks or reads the clock, so a surface left open for an hour keeps its first phrase; whether an opt-in re-rendering wrapper belongs is open.&#x22;,
  &#x22;The five event words are English and locale does not translate them, and a prop to supply others would also let a product relabel synced as updated.&#x22;,
  &#x22;The muted stale treatment is unmeasured against the muted fill, the tinted grounds it may share, and under increased contrast, and no preview renders it.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="relative-time" />

## Installation [#installation]

<ComponentInstall name="relative-time" unbuilt="false" importPath="@/components/ui/relative-time" />

## Usage [#usage]

```tsx
import { RelativeTime } from "@/components/ui/relative-time"
```

```tsx
<RelativeTime
  at="2026-03-14T08:12:00+00:00"
  event="measured"
  now={new Date().toISOString()}
  locale="en-GB"
/>
```

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

<WhenToUse
  use="[
  &#x22;Saying when a reading was measured, a result was issued, a dose was taken, or data last synced.&#x22;,
  &#x22;Any timestamp a reader will use to judge how much weight to give the thing it is attached to.&#x22;,
  &#x22;Lists of recent entries, where an age is read far faster than two dates side by side.&#x22;,
]"
  avoid="[
  { case: &#x22;The date itself is the information. That is true of an appointment, a deadline and a date of birth.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;The reader must act by a certain time and needs the actual date and time.&#x22;, instead: &#x22;care-card&#x22; },
  { case: &#x22;The event is far enough in the past that a relative phrase is less useful than the date.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;You are showing a duration rather than a point in time.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;The time is being entered rather than displayed.&#x22;, instead: &#x22;field&#x22; },
]"
/>

## Clinical meaning [#clinical-meaning]

**Asserts.** That the named event happened at that instant and, past `staleAfterHours`,
that the product no longer considers the value current. It does not
know what was measured. The phrase rounds down at every rung except the minute floor.
The staleness verdict is computed from the exact elapsed time, not the phrase, as
[Numbers, units and precision](../health/numbers-units-precision.mdx) requires.

**Never read as.** Freshness of the data. *Synced 5 minutes ago* means the app spoke to
a server, not that the readings are that new. That is why `event` is required.

**Colour axis.** Neither. The stale treatment is muted text carried by the
words *may be out of date*, never a status tint on a fact about the clock.

**Thresholds.** Staleness belongs to the product: no default, no per-event default and
nothing derived from `event`. Omit `staleAfterHours` and no stale treatment renders.
Supply it and the words appear past the boundary, or wherever it could not be applied, as
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx) asks.

**Staleness boundary.** The muted treatment reaches the `time` element only. A surface
embedding it still owes the value's de-emphasis and a route to refresh.

<SafetyCallout severity="attention" evidence="opinion">
  Never put a sync time where a measurement time belongs. *Synced 2 minutes ago* above a
  reading taken four months ago is two true statements that together mislead. Render both,
  each with its event word.
</SafetyCallout>

## Anatomy [#anatomy]

<Anatomy
  name="relative-time"
  parts="[
  {
    name: &#x22;RelativeTime&#x22;,
    describes: &#x22;The root, always a time element carrying the caller's exact timestamp in datetime. Not focusable and not interactive.&#x22;,
    prop: &#x22;at&#x22;,
  },
  {
    name: &#x22;RelativeTime.Prefix&#x22;,
    describes: &#x22;The event word: Measured, Recorded, Received, Synced or Issued. Required and never inferred.&#x22;,
    prop: &#x22;event&#x22;,
  },
  {
    name: &#x22;RelativeTime.Relative&#x22;,
    describes: &#x22;The human phrase, precision following recency. Absent for a future timestamp or when now cannot be parsed.&#x22;,
    prop: &#x22;now&#x22;,
  },
  {
    name: &#x22;RelativeTime.Absolute&#x22;,
    describes: &#x22;The exact date and time. Always in the accessibility tree and in print; on screen once older than absoluteAfterDays.&#x22;,
    prop: &#x22;showAbsolute&#x22;,
  },
  {
    name: &#x22;RelativeTime.Staleness&#x22;,
    describes: &#x22;The words 'may be out of date', past the threshold or wherever it could not be applied. Absent without one.&#x22;,
    prop: &#x22;staleAfterHours&#x22;,
  },
]"
/>

## Examples [#examples]

### A column of entries [#a-column-of-entries]

Every row names its `event`, and an age reads faster down a column than dates side by
side. No row passes `staleAfterHours`.

<ComponentPreview name="relative-time-in-a-log-list" kind="example" align="start" />

### Measured and synced are two different facts [#measured-and-synced-are-two-different-facts]

Both lines are true. The `event` prefix keeps a sync time from lending its reassurance
to a reading taken eleven days earlier.

<ComponentPreview name="relative-time-measured-and-synced" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Always name the event: *Measured 3 days ago* is a fact, *3 days ago* is a fragment. On a
result, show *received* beside *issued*, never instead of it. It never says *just now*,
*up to date* or *yesterday*, and its smallest phrase is *1 minute ago*.

<DoDont>
  <DoDont.Do>
    **"Measured 3 days ago, on 11 March 2026 at 08:12 UTC"** names the event and keeps the
    exact time beside it.
  </DoDont.Do>

  <DoDont.Dont>
    **"Updated 2 minutes ago"** above a reading taken four months ago. Both facts are true
    and together they mislead.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA** in a source pass and a rendered pass. This audit is
author-run, not an independent review, and clinical review is still pending.

The rendered pass found no axe violations, no target-size hits, no focus-visible issue
and no reflow at 320px. The source pass found one moderate item: the log-list example
rendered its rows in a `<ul>` styled `list-none`, which drops list semantics under
Safari with VoiceOver, so the rows read as generic text. A `role="list"` restores the
role with no visual or API change. The component's own source held no critical or
serious defect.

* The exact date is always in the accessibility tree and in print, never behind hover,
  focus or a disclosure. `showAbsolute` moves it on screen only.
* Staleness is stated in words, never by the muted tone alone, and the words stay at the
  surrounding foreground while the phrase recedes.
* It mounts no live region and owns no timer. Nothing changes at runtime to announce, so
  a product re-rendering it should add no `aria-live`.
* No `"use client"`, no effect and no clock read, so hydration is safe by determinism.
  Pass an explicit `locale` or server and browser may differ.

Two items remain open. The muted stale treatment's contrast against the muted or tinted
grounds it may sit on is unmeasured, and no shipped preview renders the stale state.
Screen readers in a language other than English are also unverified.

<KeyboardTable
  name="relative-time"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the timestamp&#x22;,
    notes: &#x22;Not focusable and no keyboard behaviour: it holds no control.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the timestamp&#x22;,
    notes: &#x22;Same in reverse. The exact date is reached by reading, not operating.&#x22;,
  },
]"
/>

<ContrastReport component="relative-time" />

## API reference [#api-reference]

<PropsTable name="RelativeTimeProps" />

`at`, `event` and `now` are required with no defaults. An `at` without an offset, or an
`event` outside the five, renders nothing and warns in development. An unparseable `now`
renders the date alone. `absoluteAfterDays` defaults to `1`; `0` shows the
date at every age. `TimeEvent` is exported.

## Related [#related]

* [Value](./value.mdx) covers quantities rather than instants, and renders an explicit
  absence where this component renders nothing.
* [MetricTile](./metric-tile.mdx) embeds a RelativeTime and still owes the value's
  de-emphasis and a route to refresh it.
* [ResultCard](./result-card.mdx) is where *measured* and *received* both apply and only
  one is relevant.
