opsinjs
ComponentsHealth formatting

RelativeTime

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.

Preview

Theme
Density
Text
Status
relative-time · base base · style base-lyraOpen under the product theme

Installation

pnpm dlx shadcn@latest add @opsinjs/relative-time

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 { RelativeTime } from "@/components/ui/relative-time"
<RelativeTime
  at="2026-03-14T08:12:00+00:00"
  event="measured"
  now={new Date().toISOString()}
  locale="en-GB"
/>

When to use it

Use it when

  • Saying when a reading was measured, a result was issued, a dose was taken, or data last synced.
  • Any timestamp a reader will use to judge how much weight to give the thing it is attached to.
  • Lists of recent entries, where an age is read far faster than two dates side by side.

Do not use it when

  • The date itself is the information. That is true of an appointment, a deadline and a date of birth.Use value instead.
  • The reader must act by a certain time and needs the actual date and time.Use care-card instead.
  • The event is far enough in the past that a relative phrase is less useful than the date.Use value instead.
  • You are showing a duration rather than a point in time.Use value instead.
  • The time is being entered rather than displayed.Use field instead.

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

Anatomy

  1. RelativeTimeThe root, always a time element carrying the caller's exact timestamp in datetime. Not focusable and not interactive.Controlled by at
  2. RelativeTime.PrefixThe event word: Measured, Recorded, Received, Synced or Issued. Required and never inferred.Controlled by event
  3. RelativeTime.RelativeThe human phrase, precision following recency. Absent for a future timestamp or when now cannot be parsed.Controlled by now
  4. RelativeTime.AbsoluteThe exact date and time. Always in the accessibility tree and in print; on screen once older than absoluteAfterDays.Controlled by showAbsolute
  5. RelativeTime.StalenessThe words 'may be out of date', past the threshold or wherever it could not be applied. Absent without one.Controlled by staleAfterHours

Examples

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.

Theme
Density
Text
Status
relative-time-in-a-log-list · base base · style base-lyraOpen under the product theme

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.

Theme
Density
Text
Status
relative-time-measured-and-synced · base base · style base-lyraOpen under the product theme

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.

Do

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

Don’t

"Updated 2 minutes ago" above a reading taken four months ago. Both facts are true and together they mislead.

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.

KeyActionNotes
TabSkips the timestampNot focusable and no keyboard behaviour: it holds no control.
Shift+TabSkips the timestampSame in reverse. The exact date is reached by reading, not operating.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below floor

These are the measured token pairs this component draws colour from, not a measurement of the component itself.

API reference

Prop

Type

Generated from RelativeTimeProps in registry/bases/base/relative-time.tsx.

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.

  • Value covers quantities rather than instants, and renders an explicit absence where this component renders nothing.
  • MetricTile embeds a RelativeTime and still owes the value's de-emphasis and a route to refresh it.
  • ResultCard is where measured and received both apply and only one is relevant.

On this page