Relative Time
Auto-refreshing "time ago" text — renders a real `<time>` element, formats with Intl.RelativeTimeFormat, and stays hydration-safe by reading the clock through useSyncExternalStore.
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/relative-time.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RelativeTime" component (no runtime
dependencies beyond React and the browser's built-in Intl APIs).
Contract
- Export a forwardRef<HTMLTimeElement, RelativeTimeProps> rendering a real
<time> element. Props extend Omit<React.TimeHTMLAttributes<HTMLTimeElement>,
"children">: date: string | number | Date (ISO string / epoch ms / Date
instance); locale?: string (BCP 47 like "zh-CN"; omit to use the visitor's
browser locale — pass it straight through as `undefined`, don't default to
"en"); updateInterval?: number (ms, default 60000; 0 disables the refresh
timer). className merged via cn(), rest props spread on the <time>.
Behavior
- dateTime is always the real ISO string of `date`, independent of what's
displayed — the element stays machine-readable. `title` carries the full
absolute date + time (locale-formatted via Intl.DateTimeFormat with
dateStyle: "full", timeStyle: "short") so hovering shows the exact moment
through the browser's own native tooltip — no popover/tooltip library.
- Unit selection is a coarsest-fit walk over fixed millisecond thresholds,
checked smallest to largest, each rounding the diff into that unit:
diff < 10s → "now" (format(0, "second") — Intl's own idiom)
diff < 60s → seconds
diff < 60min → minutes
diff < 24h → hours
diff < 7d → days
diff < ~1 month → weeks
diff < ~1 year → months
else → years
Format each bucket with Intl.RelativeTimeFormat(locale, { numeric: "auto" })
— "auto" lets Intl substitute an idiom ("yesterday", "昨天") wherever one
exists instead of always saying "1 day ago".
- An unparseable date ("", "n/a", a malformed API string) becomes an
Invalid Date, and toISOString() / Intl formatting both throw on it: check
Number.isNaN(dateObj.getTime()) first and render a <time> with no
dateTime and the text "—" instead of crashing the tree.
- SSR/hydration safety: render must never call Date.now() directly (the
purity rule forbids impure calls during render). Read "now" through a
useSyncExternalStore-backed clock instead: getSnapshot returns
Math.floor(Date.now() / 1000) * 1000. Flooring to whole seconds keeps
back-to-back calls equal (React Object.is-compares consecutive snapshots,
and a raw Date.now() that moved 1ms reads as a store change — extra
renders and the "getSnapshot should be cached" warning), yet every render
still reads the real clock, so a new `date` prop or a late mount is never
measured against a stale reading. getServerSnapshot always returns null. Server and the client's first paint therefore both
render a LOCALE-INDEPENDENT ISO date slice ("2026-07-26") as the
placeholder text AND title — never an Intl(undefined) string, because
the server's default locale (e.g. en_US) and the visitor's (e.g. zh_CN)
can differ, and any locale-dependent placeholder then hydration-mismatches
byte-for-byte. The moment React re-checks getSnapshot after hydration it
gets a real timestamp and re-renders into the visitor-locale relative
string and full absolute title on its own — no manual mounted-effect,
no hydration warning.
- Refresh: the store's subscribe function wires a
setInterval(callback, updateInterval); every tick re-invokes getSnapshot,
whose second has moved on since the last reading, so React re-renders with
a fresh relative string. updateInterval <= 0 makes subscribe return a
no-op unsubscribe, so the label no longer ticks on its own and only
refreshes when the component re-renders for another reason. The interval is cleared automatically on unmount / when
updateInterval changes (useSyncExternalStore re-subscribes).
Rendering & styling
- Semantic tokens only: text-muted-foreground for the default de-emphasized
timestamp look, no hardcoded color — it follows the host theme in light
and dark. cn() merges the caller's className.
- No animation and nothing to reduce-motion-gate — this component has no
motion budget.
Customization levers
- Threshold table: the millisecond boundaries and their unit are a flat,
ordered list at the top of the file — insert a coarser/finer bucket (a
longer/shorter "just now" window, a dedicated "few seconds" tier) without
touching the formatting call itself.
- Refresh cadence: updateInterval trades freshness against timer churn —
drop it to 1000 for a live ticking "3s ago" display, or make it adaptive
(fast while the shown bucket is seconds/minutes, slow once it's days or
years) by deriving the next interval from the current bucket instead of a
fixed number.
- Absolute format: the post-hydration `title` tooltip calls
Intl.DateTimeFormat once — swap dateStyle/timeStyle (e.g. "short"
instead of "full") for less hover precision. Keep the pre-hydration
placeholder ISO-based: making it locale-aware reintroduces the
server/visitor locale hydration mismatch.
- Tooltip upgrade: title gives a free native tooltip; swap it for a shadcn
Tooltip trigger if the product needs richer hover content (e.g. also
showing the visitor's own timezone next to the absolute time).Concepts
- SSR-safe relative clock — "now" is never read directly during render; it comes from a store that the server and the client's first paint both see as empty, so there is nothing to mismatch on hydration.
- useSyncExternalStore as a clock — this hook isn't only for
matchMedia/localStorage-style external sources; the wall clock itself is an external, mutable value.getSnapshotfloorsDate.now()to the second, so back-to-back reads compare equal (no spurious re-render) while every render still measures against the current time. - Coarsest-fit unit thresholds — the diff is bucketed into the largest unit it still fits under (seconds → minutes → hours → days → weeks → months → years), so a two-day-old comment reads "2 days ago", not "172,800 seconds ago".
- Native
<time>element —dateTimealways carries the real ISO timestamp regardless of the human-readable text shown, so crawlers, browsers, and assistive tech still get a machine-readable date. titleas native tooltip — the exact absolute date/time rides the standard HTMLtitleattribute, giving a real hover tooltip for free with zero extra JS or floating-UI dependency.
Chat Bubble
A single message bubble — role-based alignment and color, avatar slot, and a bouncing typing indicator — for assembling chat and support message lists.
Description List
A semantic key-value list for order details, profile echoes, and settings summaries, in a divided-rows or panel-grid layout.