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
relative-time · base base · style base-lyraOpen under the product themeInstallation
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
valueinstead. - The reader must act by a certain time and needs the actual date and time.Use
care-cardinstead. - The event is far enough in the past that a relative phrase is less useful than the date.Use
valueinstead. - You are showing a duration rather than a point in time.Use
valueinstead. - The time is being entered rather than displayed.Use
fieldinstead.
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
RelativeTimeThe root, always a time element carrying the caller's exact timestamp in datetime. Not focusable and not interactive.Controlled byatRelativeTime.PrefixThe event word: Measured, Recorded, Received, Synced or Issued. Required and never inferred.Controlled byeventRelativeTime.RelativeThe human phrase, precision following recency. Absent for a future timestamp or when now cannot be parsed.Controlled bynowRelativeTime.AbsoluteThe exact date and time. Always in the accessibility tree and in print; on screen once older than absoluteAfterDays.Controlled byshowAbsoluteRelativeTime.StalenessThe words 'may be out of date', past the threshold or wherever it could not be applied. Absent without one.Controlled bystaleAfterHours
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.
relative-time-in-a-log-list · base base · style base-lyraOpen under the product themeMeasured 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.
relative-time-measured-and-synced · base base · style base-lyraOpen under the product themeContent 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.
showAbsolutemoves 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 explicitlocaleor 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Skips the timestamp | Not focusable and no keyboard behaviour: it holds no control. |
| Shift+Tab | Skips the timestamp | Same in reverse. The exact date is reached by reading, not operating. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| 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 |
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.
Related
- 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.
Value
A number and its unit, formatted the same way everywhere. It is rounded honestly, spoken correctly, and never shown to more precision than the measurement has.
Table
Rows and columns in a real semantic table, the readable twin a chart ships beside it, with a required caption, right-aligned numeric columns and its own scroll.