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…
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".
- 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
Date.now() (a fresh read on every call, which is exactly what
useSyncExternalStore exists to do safely), while 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,
which always differs from the last reading, so React re-renders with a
fresh relative string. updateInterval <= 0 makes subscribe return a
no-op unsubscribe, so the displayed value freezes at whatever it read on
mount. 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, andgetSnapshotreadingDate.now()fresh on every call is exactly the pattern the hook exists to make safe. - 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.