Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const WEEK = 7 * DAY
const MONTH = 30 * DAY
const YEAR = 365 * DAY

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/relative-time.json

Prompt

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, and getSnapshot reading Date.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> elementdateTime always carries the real ISO timestamp regardless of the human-readable text shown, so crawlers, browsers, and assistive tech still get a machine-readable date.
  • title as native tooltip — the exact absolute date/time rides the standard HTML title attribute, giving a real hover tooltip for free with zero extra JS or floating-UI dependency.

On This Page