Skip to content

“3 minutes ago” that wakes only when its words change, digits rolling.

Dates & time@base-ui/react@number-flow/reactmotion

01Preview

Activity

  • Maya Chen deployed web@4f2a1c to production

  • Jonas Weber commented on q3-forecast.xlsx

  • Stripe marked invoice INV-2041 as paid

  • Priya Nair joined Design

  • Certificates renew for app.northwind.io

Times update live

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react @number-flow/react motion

03Usage

import { RelativeTime } from "@/components/ui/relative-time";

<RelativeTime date={comment.createdAt} />

// Short words, and stay relative forever.
<RelativeTime date={deploy.finishedAt} format="narrow" absoluteAfter={Infinity} />

04Source

"use client";
import { Tooltip } from "@base-ui/react/tooltip";
import NumberFlow from "@number-flow/react";
import { useReducedMotion } from "motion/react";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";

type Unit = "second" | "minute" | "hour" | "day" | "week" | "month" | "year";
type DateInput = Date | string | number;

const SEC = 1000;
const MIN = 60 * SEC;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30.4375 * DAY;
const YEAR = 365.25 * DAY;
/** Each band: below `until` of elapsed time, count in `unit`, changing every `every` ms. */
const BANDS: { until: number; unit: Unit; size: number; every: number }[] = [
  { until: MIN, unit: "second", size: SEC, every: 10 * SEC },
  { until: HOUR, unit: "minute", size: MIN, every: MIN },
  { until: DAY, unit: "hour", size: HOUR, every: HOUR },
  { until: WEEK, unit: "day", size: DAY, every: DAY },
  { until: 5 * WEEK, unit: "week", size: WEEK, every: WEEK },
  { until: YEAR, unit: "month", size: MONTH, every: MONTH },
  { until: Infinity, unit: "year", size: YEAR, every: YEAR },
];
// A timestamp a few seconds in the future is almost always clock skew, not a plan.
const SKEW = 30 * SEC;

const toTime = (d: DateInput) => (d instanceof Date ? d.getTime() : typeof d === "number" ? d : Date.parse(d));

export type RelativeTimeFormat = "long" | "short" | "narrow";

type Resolved =
  | {
      kind: "relative";
      unit: Unit;
      value: number;
      future: boolean;
      next: number | null;
    }
  | { kind: "absolute"; next: number | null };

/** Which words to show at `now`, and how many ms until they change. */
function resolve(at: number, now: number, absoluteAfter: number): Resolved {
  const diff = at - now;
  const future = diff > SKEW;
  const abs = future ? diff : Math.max(0, -diff);
  if (abs >= absoluteAfter) return { kind: "absolute", next: future ? abs - absoluteAfter + 1 : null };
  const band = BANDS.find((b) => abs < b.until)!;
  // Seconds count in tens: "now", "10 seconds ago", "20 seconds ago".
  const bucket = band.unit === "second" ? band.every : band.size;
  const value = band.unit === "second" ? Math.floor(abs / bucket) * 10 : Math.floor(abs / bucket);
  let next = future ? abs % bucket || bucket : bucket - (abs % bucket);
  if (!future) next = Math.min(next, absoluteAfter - abs);
  return {
    kind: "relative",
    unit: band.unit,
    value,
    future,
    next: Math.min(next + 5, HOUR),
  };
}

/* ------------------------------------------------------------------------ */
/* One clock per instance. It only wakes when the words would change, sleeps */
/* while the tab is hidden, and catches up the moment the tab comes back.    */

const noop = () => () => {};
const stamp = () => ({ now: Date.now() });

function useNow(at: number, live: boolean, absoluteAfter: number) {
  const [clock] = useState(stamp);
  const subscribe = useCallback(
    (notify: () => void) => {
      let timer = 0;
      const tick = () => {
        window.clearTimeout(timer);
        clock.now = Date.now();
        notify();
        if (!live || document.hidden || Number.isNaN(at)) return;
        const next = resolve(at, clock.now, absoluteAfter).next;
        if (next != null) timer = window.setTimeout(tick, next);
      };
      const onVisibility = () => (document.hidden ? window.clearTimeout(timer) : tick());
      tick();
      document.addEventListener("visibilitychange", onVisibility);
      return () => {
        window.clearTimeout(timer);
        document.removeEventListener("visibilitychange", onVisibility);
      };
    },
    [clock, at, live, absoluteAfter],
  );
  // The server can't know the reader's clock: it renders a fixed date, and the
  // client swaps in the relative words right after hydration without a mismatch.
  return useSyncExternalStore(
    subscribe,
    () => clock.now,
    () => null,
  );
}

function useLocale(locale?: string) {
  const detected = useSyncExternalStore(
    noop,
    () => Intl.DateTimeFormat().resolvedOptions().locale,
    () => "en-US",
  );
  return locale ?? detected;
}

export type UseRelativeTimeOptions = {
  format?: RelativeTimeFormat;
  numeric?: "auto" | "always";
  absoluteAfter?: number;
  live?: boolean;
  locale?: string;
};

/** The words on their own: `{ text, parts, absolute, full, iso }`, updating on the same schedule. */
export function useRelativeTime(
  date: DateInput,
  { format = "long", numeric = "auto", absoluteAfter = WEEK, live = true, locale: localeProp }: UseRelativeTimeOptions = {},
) {
  const at = toTime(date);
  const locale = useLocale(localeProp);
  const now = useNow(at, live, absoluteAfter);

  return useMemo(() => {
    const valid = !Number.isNaN(at);
    const d = new Date(valid ? at : 0);
    const iso = valid ? d.toISOString() : undefined;
    if (!valid)
      return {
        text: "Unknown date",
        parts: null,
        absolute: "Unknown date",
        full: null,
        iso,
        unit: null,
      };
    if (now == null) {
      const text = new Intl.DateTimeFormat("en-US", {
        dateStyle: "medium",
        timeZone: "UTC",
      }).format(d);
      return { text, parts: null, absolute: text, full: null, iso, unit: null };
    }
    const sameYear = new Date(now).getFullYear() === d.getFullYear();
    const absolute = new Intl.DateTimeFormat(locale, sameYear ? { month: "short", day: "numeric" } : { dateStyle: "medium" }).format(d);
    const zone = new Intl.DateTimeFormat(locale, { timeZoneName: "long" }).formatToParts(d).find((p) => p.type === "timeZoneName")?.value;
    const offset = new Intl.DateTimeFormat(locale, {
      timeZoneName: "shortOffset",
    })
      .formatToParts(d)
      .find((p) => p.type === "timeZoneName")?.value;
    const full = {
      date: new Intl.DateTimeFormat(locale, {
        dateStyle: "full",
        timeStyle: "short",
      }).format(d),
      zone: [zone, offset].filter(Boolean).join(" · "),
    };
    const r = resolve(at, now, absoluteAfter);
    if (r.kind === "absolute") return { text: absolute, parts: null, absolute, full, iso, unit: null };

    const rtf = new Intl.RelativeTimeFormat(locale, { style: format, numeric });
    const signed = r.future ? r.value : -r.value;
    const raw = rtf.formatToParts(signed === 0 ? 0 : signed, r.unit);
    // Split around the number so it can roll while the words stay put: ["", 3, " minutes ago"].
    const first = raw.findIndex((p) => p.type !== "literal");
    let last = -1;
    raw.forEach((p, i) => {
      if (p.type !== "literal") last = i;
    });
    const parts =
      first < 0
        ? null
        : {
            before: raw
              .slice(0, first)
              .map((p) => p.value)
              .join(""),
            value: r.value,
            after: raw
              .slice(last + 1)
              .map((p) => p.value)
              .join(""),
          };
    return {
      text: raw.map((p) => p.value).join(""),
      parts,
      absolute,
      full,
      iso,
      unit: r.unit,
    };
  }, [at, now, locale, format, numeric, absoluteAfter]);
}

/* ------------------------------------------------------------------------ */

export type RelativeTimeProps = Omit<React.ComponentProps<"time">, "children" | "dateTime"> & {
  /** A Date, a timestamp in ms, or anything Date.parse reads (ISO strings). */
  date: DateInput;
  /** How much to spell out: "3 minutes ago", "3 min. ago", "3m ago". */
  format?: RelativeTimeFormat;
  /** "auto" says "yesterday" and "now"; "always" says "1 day ago" and "0 seconds ago". */
  numeric?: "auto" | "always";
  /** Milliseconds after which it shows the date instead ("12 Sep"). Pass Infinity to stay relative. */
  absoluteAfter?: number;
  /** Keep the words current. Off, it renders once. */
  live?: boolean;
  /** Show the full date, time and zone on hover and focus. */
  tooltip?: boolean;
  locale?: string;
};

export function RelativeTime({
  date,
  format = "long",
  numeric = "auto",
  absoluteAfter = WEEK,
  live = true,
  tooltip = true,
  locale,
  className,
  ...rest
}: RelativeTimeProps) {
  const reduce = useReducedMotion();
  const t = useRelativeTime(date, {
    format,
    numeric,
    absoluteAfter,
    live,
    locale,
  });

  const body = (
    <time
      dateTime={t.iso}
      data-state={t.unit ? "relative" : "absolute"}
      data-unit={t.unit ?? undefined}
      className={cn("tabular whitespace-nowrap", className)}
      {...rest}
    >
      {t.parts ? (
        <>
          {t.parts.before}
          {/* Keyed by unit: 50 seconds becoming 1 minute swaps, it doesn't roll backwards. */}
          {/* NumberFlow's mask makes it taller than the line; a line-high box keeps rows from growing. */}
          <span className="inline-flex h-[1lh] items-center align-top">
            <NumberFlow
              key={t.unit}
              aria-hidden
              value={t.parts.value}
              locales={locale}
              animated={!reduce}
              transformTiming={{
                duration: 420,
                easing: `cubic-bezier(${ease.out.join(",")})`,
              }}
              spinTiming={{
                duration: 420,
                easing: `cubic-bezier(${ease.out.join(",")})`,
              }}
              opacityTiming={{ duration: 180, easing: "ease-out" }}
            />
          </span>
          <span className="sr-only">{t.parts.value}</span>
          {t.parts.after}
        </>
      ) : (
        t.text
      )}
    </time>
  );

  if (!tooltip || !t.full) return body;
  return (
    <Tooltip.Root>
      <Tooltip.Trigger delay={500} render={body} />
      <Tooltip.Portal>
        <Tooltip.Positioner side="top" sideOffset={6} className="z-(--z-tooltip)">
          <Tooltip.Popup
            className={cn(
              "origin-(--transform-origin) rounded-lg border border-line-2 bg-raised px-2.5 py-1.5 text-left shadow-pop",
              "transition-[opacity,scale,filter] duration-150 ease-out-expo",
              "data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]",
              "data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none",
              "motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none",
            )}
          >
            <p className="whitespace-nowrap text-[12px] leading-4 text-fg">{t.full.date}</p>
            {t.full.zone && <p className="mt-0.5 whitespace-nowrap text-[11px] leading-4 text-fg-3">{t.full.zone}</p>}
          </Tooltip.Popup>
        </Tooltip.Positioner>
      </Tooltip.Portal>
    </Tooltip.Root>
  );
}

05Props

RelativeTime

PropTypeDefaultDescription
date*Date | string | numberThe moment to describe: a Date, a timestamp in ms, or an ISO string. Unparseable input renders “Unknown date”.
format"long" | "short" | "narrow""long"“3 minutes ago”, “3 min. ago” or “3m ago”, in the reader's language.
numeric"auto" | "always""auto"auto says “yesterday” and “now”; always says “1 day ago”.
absoluteAfternumber604800000 (7 days)Milliseconds after which it shows the date instead, with the year only when it differs. Infinity stays relative.
livebooleantrueKeep the words current. Off, it renders once at mount.
tooltipbooleantrueFull date, time and time zone on hover.
localestringBCP 47 locale. Defaults to the reader's.

useRelativeTime

PropTypeDefaultDescription
date*Date | string | numberFirst argument. Returns { text, parts, absolute, full, iso, unit } on the same update schedule, for rendering your own markup.
options{ format, numeric, absoluteAfter, live, locale }Second argument, the same options as the component.

06Notes

Behavior

  • Each instance sets one timer for the exact moment its words next change: every 10 seconds under a minute, on the minute under an hour, on the hour under a day. A feed of 200 timestamps is mostly asleep.
  • Timers stop while the tab is hidden and catch up the moment it's visible again, so coming back after lunch rolls “4 minutes ago” straight to the right answer.
  • Hydration safe: the server renders a fixed UTC date (it can't know the reader's clock or zone) and the client swaps in the relative words right after hydration, with no mismatch.
  • A timestamp up to 30 seconds in the future reads “now”, because that's server clock skew, not a plan. Real future times read “in 3 hours”, then flip to a date past the threshold.

Motion

  • The number rolls to its next value (NumberFlow, 420ms expo ease-out) while the words around it stay put, so the change reads as time passing rather than a flash.
  • When the unit changes (50 seconds becoming 1 minute) the number swaps instead of rolling backwards.
  • The tooltip grows from the time: scale 0.96 and a 2px blur that clears, 150ms in, 100ms out, instant when moving between times. Reduced motion keeps a plain fade and no roll.

Accessibility

  • A real time element with a machine-readable dateTime, so assistive tech and parsers get the exact moment.
  • Deliberately not a live region: a timestamp that re-announced itself every minute would be noise.
  • The tooltip is supplementary; the visible words carry the meaning on their own, including on touch where tooltips don't open.