Composites

Relative Time

A <time> element that reads “12 minutes ago” and keeps itself current, with the exact timestamp in its tooltip. Two things decide whether it behaves: the locale (Vietnamese by default) and whether you hand it a fixed now.

Basic usage

Give it an instant and a locale. The demo below reads the browser clock when it mounts, so these are genuinely live — the one-minute refresh will move the top row on its own if you leave the page open.

Reading the clock…

What counts as a value

Date, ISO string, or epoch milliseconds — the component normalises all three. Passing the API response through untouched is the intended usage; there is no reason to construct a Date first.

Date objectISO stringepoch ms

English apps must pass a locale

The default is date-fns' vi, in line with every other default string in the package. Nothing errors when you omit it — the interface simply goes bilingual, which is exactly the kind of bug that survives review. The two rows below are the same instant and the same reference.

no locale proplocale={enUS}

locale is also what the tooltip is formatted with, so one prop settles both the relative phrase and the absolute timestamp. There is no separate locale for the title.

now — freezing the reference

By default the component measures against the real clock, which is right for records that came out of a database. It is wrong for data that does not move: a fixture, a design mock-up, a report screenshot, a snapshot test. Measured against the real clock, a hard coded timestamp drifts a little further every day, and one that happens to sit in the future prints “in 2 hours” — a sentence neither TypeScript nor a linter has any objection to.

Pass now and three things follow: the string is computed from a fixed pair of instants so it never changes; the refresh timer is switched off regardless of updateIntervalMs; and server and client produce the same markup, which makes the component's suppressHydrationWarning moot rather than load-bearing. Every deterministic demo on this page uses it.

now = 2026-08-29T09:12:00Z (fixed for every row below)

09:00:00Z2026-08-0311:12:00Z (future)

The two measuring paths deliberately share one phrasing — formatDistanceStrict against now, and formatDistanceToNowStrict against the real clock — so switching between them never changes the register of the sentence. A fixture still reads “12 minutes ago”, not “about 12 minutes ago”.

Strict distances, on purpose

The component uses the strict date-fns formatters, not the ordinary ones. The ordinary formatter hedges: it collapses anything under 30 seconds into “less than a minute”, calls the next minute-and-a-half “1 minute”, and prefixes “about” to hours and months. The strict formatter reports the count it measured. The left column below is the live component; the right is what the non-strict formatter prints for the same instants.

ElapsedRelativeTimeNon-strict
20 secondsless than a minute ago
30 seconds1 minute ago
1 hourabout 1 hour ago
3 hoursabout 3 hours ago
35 daysabout 1 month ago

Strict is not unlimited precision. It still picks one unit and rounds inside it, and it rolls over to months at 30 days — so 35 days and 44 days both read “1 month ago”. What it removes is the hedging, not the rounding. Below 30 days the two formatters agree exactly (26 days is “26 days ago” either way); the component source names a wider 26-to-45-day window, but the measured boundary in date-fns 4.1.0 is 30 days.

Keeping itself current

Without a fixed now, an interval re-renders the component so the string does not go stale on a page someone leaves open. One minute is the default — fine for “3 hours ago”, too slow if you are showing seconds. Set 0 to switch the timer off. The two boxes below mounted at the same moment.

updateIntervalMs={1000}

updateIntervalMs={0}

No timer — the text stays at the value it had when this box mounted.

The interval is a re-render, not a subscription — it bumps a counter in state and lets the formatter run again. Two consequences: it costs nothing measurable, and it is fully ignored when now is set. The source is explicit about the second point, because the alternative is a timer that wakes up every minute to render the identical string.

Direction

addSuffix is on by default and is what distinguishes past from future — “3 hours ago” against “in 2 hours”. Turning it off gives you a bare duration, which is useful in a column already headed “Age” but collapses the two directions into the same string everywhere else.

InstantDefaultaddSuffix={false}3 hours before3 hours after

The exact timestamp

Relative wording is easy to read and useless for reconciling anything, so the element always carries the absolute timestamp in its title. Hover the three below to compare. titleFormat takes a date-fns format string and defaults to 'Pp' — the localized short date plus time, which means it re-orders itself per locale with no extra configuration: an enUS instance lays it out as MM/DD/YYYY, h:mm AM/PM where the Vietnamese default gives DD/MM/YYYY HH:mm. The clock reading itself is rendered in the viewer's own timezone — the instants on this page are fixed in UTC, so the hour you see depends on where you are.

Before 0.9.0 the default was the literal string "HH:mm 'ngày' dd/MM/yyyy", which welded a Vietnamese word into every language the component was used in. If you want that exact sentence back, pass it explicitly.

'Pp' (default)'PPPPp''yyyy-MM-dd HH:mm'

The dotted underline above is added by this page, not the component — it renders an unstyled <time>. If a timestamp genuinely matters to the task, put it on screen rather than relying on a native tooltip, which no keyboard or touch user can reach.

Invalid values

Null timestamps and empty strings arrive from real APIs constantly. When value does not parse, the component renders a plain <span> containing fallback and no <time> element at all. That is the correct call rather than a defensive nicety: <time> is only meaningful with a valid datetime, and emitting datetime="Invalid Date" publishes broken machine readable data to screen readers and crawlers alike. Your className is applied to the span too, so the column does not jump.

valid'' (empty)fallback="Never"Never

Props

PropTypeDefaultDescription
valuerequiredDate | string | numberThe instant to describe — a Date, an ISO string straight off an API, or epoch milliseconds. Anything unparseable takes the fallback branch.
nowDateMeasure from this fixed instant instead of the real clock. Makes the output deterministic (server and client agree) and disables the refresh timer entirely. For frozen data: fixtures, screenshots, tests.
addSuffixbooleantrueAdds the directional wording — "ago" for the past, "in" for the future. Turn it off only when the surrounding copy already says which way time is running.
updateIntervalMsnumber60_000How often the component re-renders so the string stays honest. 0 turns the timer off. Ignored when now is set, because a fixed reference cannot produce a new string.
localeLocale (date-fns)viDrives both the relative string and the absolute timestamp in the tooltip. A non-Vietnamese app must pass this — leaving it out silently prints "3 phút trước" in an otherwise English UI.
titleFormatstring'Pp'date-fns format string for the title attribute. The default is the localized short date + time, so it follows locale without further configuration.
fallbackstring'—'Rendered instead of the <time> element when value is not a valid date.
classNamestringApplied to the <time>, and to the fallback <span> as well — styling holds in both branches.
...restOmit<React.ComponentProps<'time'>, 'dateTime' | 'children'>Every other time-element prop is forwarded to the <time>. Only className crosses to the fallback branch — the invalid-value <span> spreads nothing, so a forwarded lang, id or data-* disappears when value does not parse. dateTime and children are removed because the component owns them: dateTime is derived from value, and the text content is the formatted distance.

date-fns is a peer of the package and Locale is its type — import the locale you need from date-fns/locale. Locales are individual modules, so importing enUS does not pull in the other two hundred.

Accessibility

  • Renders a semantic <time> with dateTime set to the full ISO string. The visible text can be as loose as “8 months ago” while the machine-readable value stays exact.
  • An invalid value drops the <time> entirely rather than emitting an invalid datetime. Assistive technology and crawlers get nothing rather than something false.
  • The exact timestamp lives in the native title tooltip, which is not reachable by keyboard and does not appear on touch. Treat it as an enhancement; when the precise moment is part of the task, render it as text.
  • The refresh is a silent re-render — there is no aria-live region. Deliberate: a list of twenty timestamps quietly rewriting itself every minute would produce twenty announcements a minute for no benefit.
  • suppressHydrationWarning is set because the server clock and the browser clock are never quite the same instant. It suppresses a warning, not a mismatch — pass now when you want the two renders to genuinely agree.
  • Colour is entirely yours: the element ships with no colour classes, so it inherits from its container and cannot break either palette. Style it through className with role tokens like text-muted-foreground.
  • locale governs the language of the rendered text, but the component does not set lang. If a relative time sits in a page of a different language, put the lang attribute on it (it is forwarded through the rest spread — on the valid branch; the fallback span takes className only) so it is pronounced correctly.