Skip to content
Date & Time primitive

Relative Time

A real `<time>` that says "3 hours ago" and cannot mismatch on hydration: both sides render the absolute date first, and the relative wording only arrives in an effect. Ticks at an interval scaled to its own age.

165 linesNo dependenciesAdded 13 Sept 2026
  • time
  • relative
  • timestamp
  • ago
  • hydration

What's included

  • components/ui/relative-time.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add relative-time

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Deployed
Last sync
Trial ends

Source

One file. Create components/ui/relative-time.tsx and paste.

components/ui/relative-time.tsx
'use client'

/**
 * <RelativeTime> — "3 hours ago", without the hydration bug.
 *
 * Every app writes this and most write it twice, because the obvious version
 * is wrong in a way that only shows up in production:
 *
 *   **It cannot be rendered on the server.** The server formats "2 minutes
 *   ago" at one instant and the browser re-renders it at another, React
 *   compares the two strings, and you get a hydration mismatch on a page
 *   that looked fine in dev. The fix here is that the first client render is
 *   *identical to the server's by construction*: `now` starts as `null` and
 *   both sides render the absolute date. The relative string only appears in
 *   an effect, which never runs on the server and always runs after
 *   hydration has matched.
 *
 *   **It goes stale.** A timestamp that says "1 minute ago" for the twenty
 *   minutes somebody left the tab open is worse than a fixed date. The tick
 *   interval scales with the magnitude — every 10s under a minute, every
 *   minute under an hour, hourly after that — so a feed of a hundred of
 *   these is not a hundred one-second timers.
 *
 *   **Its ABSOLUTE half has the same bug, one layer down.** The fix above
 *   makes both sides render the absolute date — which only helps if the two
 *   sides format it the same way, and by default they do not.
 *   `Intl.DateTimeFormat(undefined, …)` resolves the *runtime's* locale and
 *   zone: Node's on the server (24-hour, UTC on a typical host) and the
 *   reader's in the browser. "14 Sept 2026, 20:12" against "14 Sept 2026,
 *   8:12 pm", on the exact render this component exists to get right. So
 *   the pre-mount format is pinned to a fixed locale and UTC and the
 *   reader's own formatting takes over in the effect, where nothing can
 *   disagree with it. See `absolute` below.
 *
 * The rendered element is a real `<time dateTime={...}>` carrying the
 * machine-readable instant, with the full absolute date in `title`. That is
 * what makes it copyable, translatable and meaningful to a crawler, and it
 * is why this is not a `<span>`.
 *
 * `Intl.RelativeTimeFormat` does the wording, so "hace 3 horas" costs a
 * locale prop rather than a translation table.
 */

import * as React from 'react'

export interface RelativeTimeProps {
  /** The instant being described. */
  date: Date | string | number
  /** BCP 47 tag. Defaults to the browser's. */
  locale?: string
  /** `'long'` → "3 hours ago", `'narrow'` → "3h ago". */
  style?: 'long' | 'short' | 'narrow'
  /**
   * Past this many seconds, show the absolute date instead. Nobody reads
   * "14 months ago" as a date; default is 30 days.
   */
  absoluteAfter?: number
  className?: string
}

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

/**
 * What the absolute date is formatted in before mount, when the caller
 * named no locale. Any fixed tag would do — this one only has to be the
 * same on the server and in the browser.
 */
const SSR_LOCALE = 'en-GB'

/** Largest unit that divides the gap, so 90 minutes reads "1 hour" not "90 minutes". */
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
  ['year', YEAR],
  ['month', MONTH],
  ['week', WEEK],
  ['day', DAY],
  ['hour', HOUR],
  ['minute', MINUTE],
  ['second', 1],
]

/** How often this instant's wording could change. See the header. */
function tickInterval(seconds: number): number {
  const gap = Math.abs(seconds)
  if (gap < MINUTE) return 10_000
  if (gap < HOUR) return 60_000
  return 3_600_000
}

export function RelativeTime({
  date,
  locale,
  style = 'long',
  absoluteAfter = 30 * DAY,
  className = '',
}: RelativeTimeProps) {
  const instant = React.useMemo(() => new Date(date), [date])

  /* null until mounted — this is the whole hydration story, see the header. */
  const [now, setNow] = React.useState<number | null>(null)

  React.useEffect(() => {
    const tick = () => setNow(Date.now())
    tick()
    let timer: ReturnType<typeof setTimeout>
    const schedule = () => {
      const seconds = (Date.now() - instant.getTime()) / 1000
      timer = setTimeout(() => {
        tick()
        schedule()
      }, tickInterval(seconds))
    }
    schedule()
    return () => clearTimeout(timer)
  }, [instant])

  /*
   * Mount is the only thing `absolute` needs from the clock, so it depends
   * on the flag rather than on `now` — otherwise every tick would rebuild
   * a formatter whose output cannot have changed.
   */
  const mounted = now !== null

  const absolute = React.useMemo(() => {
    // Pinned only while both of these hold: the caller named no locale (an
    // explicit one already agrees on both sides), and we have not mounted
    // yet (after that, the reader's own format is the right answer and the
    // server is no longer in the conversation). Same bargain, and the same
    // reason, as `formatAdded` in `lib/recency`.
    const pinned = locale === undefined && !mounted
    return new Intl.DateTimeFormat(pinned ? SSR_LOCALE : locale, {
      dateStyle: 'medium',
      timeStyle: 'short',
      timeZone: pinned ? 'UTC' : undefined,
    }).format(instant)
  }, [locale, instant, mounted])

  const text = React.useMemo(() => {
    if (now === null) return absolute

    const seconds = Math.round((instant.getTime() - now) / 1000)
    if (Math.abs(seconds) > absoluteAfter) return absolute

    const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style })
    for (const [unit, size] of UNITS) {
      if (Math.abs(seconds) >= size || unit === 'second') {
        /* Truncate toward zero: 59 minutes is "59 minutes ago", not "1 hour
           ago", and rounding away from zero would put it in the future. */
        return format.format(Math.trunc(seconds / size), unit)
      }
    }
    return absolute
  }, [now, instant, locale, style, absoluteAfter, absolute])

  return (
    <time dateTime={instant.toISOString()} title={absolute} className={className}>
      {text}
    </time>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
daterequiredThe instant being described.Date | string | number—
localeBCP 47 tag. Defaults to the browser's.string—
style`'long'` → "3 hours ago", `'narrow'` → "3h ago".'long' | 'short' | 'narrow''long'
absoluteAfterPast this many seconds, show the absolute date instead. Nobody reads "14 months ago" as a date; default is 30 days.number30 * DAY
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More date & time

View all
Open the full page for this primitive

Calendar

A real `role="grid"` month rather than 42 tab stops — one tab stop, arrows for days, PageUp/PageDown for months, and six rows always, so paging never changes the height of what sits below it.

Date & Time418 lines1 dep
Open the full page for this primitive

Date Picker

The typing half most pickers drop, attached to the calendar: parses on blur, refuses to guess whether 3/4 is March or April, and leaves what it cannot read in the box instead of clearing it.

Date & Time688 lines1 dep
Open the full page for this primitive

Time Picker

Slots at a fixed step with the taken ones struck through and the time zone rendered beside the value — the three things a booking needs and a native time input cannot say.

Date & Time266 lines1 dep