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…

Registry dependencies
utils
"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".
- 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. getSnapshot floors Date.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 — dateTime 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