Relative Time
Data Display

Relative Time

A live relative timestamp — just now through years, futures included — that wakes exactly at the next label flip, pauses offscreen and in hidden tabs, rolls label changes, and reveals the absolute instant on hover.

Install

npx shadcn@latest add @paragon/relative-time

Also installs: tooltip

relative-time.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

/* ------------------------------------------------------------------
 * Hand-rolled relative-time math — no date libraries. Sub-day units
 * use real elapsed milliseconds; day-and-up units compare calendar
 * days through Date.UTC so DST can never skew "yesterday".
 * ------------------------------------------------------------------ */

const MINUTE = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

const WEEKDAYS = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
] as const;

/** Whole calendar days from `b` to `a` (positive when `a` is later). */
function calendarDayDiff(a: Date, b: Date): number {
  return Math.round(
    (Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()) -
      Date.UTC(b.getFullYear(), b.getMonth(), b.getDate())) /
      DAY,
  );
}

/** Whole calendar months from `b` to `a`, day-of-month aware. */
function calendarMonthDiff(a: Date, b: Date): number {
  let months =
    (a.getFullYear() - b.getFullYear()) * 12 + (a.getMonth() - b.getMonth());
  if (months > 0 && a.getDate() < b.getDate()) months -= 1;
  if (months < 0 && a.getDate() > b.getDate()) months += 1;
  return months;
}

function msToNextMidnight(now: Date): number {
  return (
    new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() -
    now.getTime()
  );
}

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

interface RelativePart {
  label: string;
  /** Milliseconds until this label could change. */
  next: number;
}

function unitLabel(
  n: number,
  long: string,
  short: string,
  past: boolean,
  format: RelativeTimeFormat,
): string {
  if (format === "short") return past ? `${n}${short}` : `in ${n}${short}`;
  const noun = `${n} ${long}${n === 1 ? "" : "s"}`;
  return past ? `${noun} ago` : `in ${noun}`;
}

function relativePart(
  date: Date,
  now: Date,
  format: RelativeTimeFormat,
): RelativePart {
  const elapsed = now.getTime() - date.getTime();
  const past = elapsed >= 0;
  const abs = Math.abs(elapsed);
  const dayDiff = Math.abs(calendarDayDiff(now, date));

  if (abs < MINUTE) {
    if (format === "short") return { label: "now", next: past ? MINUTE - abs : abs };
    return past
      ? { label: "just now", next: MINUTE - abs }
      : { label: "in less than a minute", next: abs };
  }
  if (abs < HOUR) {
    const n = Math.floor(abs / MINUTE);
    return {
      label: unitLabel(n, "minute", "m", past, format),
      // Future labels flip as `abs` crosses a multiple downward; an exact
      // boundary re-checks almost immediately instead of skipping a unit.
      next: past ? MINUTE - (abs % MINUTE) : abs % MINUTE || 1,
    };
  }
  // Prefer hour precision inside 24 real hours ("3 hours ago" beats
  // "yesterday" at 2am); dayDiff === 0 also lands here on 25-hour DST days.
  if (abs < DAY || dayDiff === 0) {
    const n = Math.floor(abs / HOUR);
    return {
      label: unitLabel(n, "hour", "h", past, format),
      next: past ? HOUR - (abs % HOUR) : abs % HOUR || 1,
    };
  }

  // Day-granularity labels only flip when `now` crosses local midnight.
  const next = msToNextMidnight(now);
  if (dayDiff === 1 && format === "long") {
    return { label: past ? "yesterday" : "tomorrow", next };
  }
  if (dayDiff < 7) {
    return { label: unitLabel(dayDiff, "day", "d", past, format), next };
  }
  const months = Math.abs(calendarMonthDiff(now, date));
  if (months < 1) {
    const weeks = Math.max(1, Math.floor(dayDiff / 7));
    return { label: unitLabel(weeks, "week", "w", past, format), next };
  }
  if (months < 12) {
    return { label: unitLabel(months, "month", "mo", past, format), next };
  }
  const years = Math.floor(months / 12);
  return { label: unitLabel(years, "year", "y", past, format), next };
}

/** "3 hours ago", "yesterday", "in 2 weeks" — or "3h", "1d", "in 2w". */
export function formatRelativeTime(
  date: Date,
  now: Date,
  format: RelativeTimeFormat = "long",
): string {
  return relativePart(date, now, format).label;
}

/** "Tuesday, July 14, 2026 at 9:41 AM" — deterministic, locale-independent. */
export function formatAbsoluteTime(d: Date): string {
  const h12 = d.getHours() % 12 || 12;
  const mm = String(d.getMinutes()).padStart(2, "0");
  const meridiem = d.getHours() < 12 ? "AM" : "PM";
  return `${WEEKDAYS[d.getDay()]}, ${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} at ${h12}:${mm} ${meridiem}`;
}

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface RelativeTimeProps
  extends Omit<React.ComponentProps<"time">, "dateTime" | "children"> {
  /** The instant being described. */
  date?: Date;
  /** Base "now". Pass a fixed date for deterministic renders; the label
   * still ticks forward from it. */
  now?: Date;
  format?: RelativeTimeFormat;
  /** Keep the label ticking as time passes. */
  live?: boolean;
  /** Reveal the absolute instant in a tooltip on hover. */
  tooltip?: boolean;
  /** Disables the label roll transition. */
  static?: boolean;
}

/**
 * A live relative timestamp — "just now" through "2 years ago", futures
 * included — that schedules itself to wake exactly when its label next
 * flips (minute edge, hour edge, local midnight) instead of polling.
 * Ticking pauses offscreen and in hidden tabs, label changes roll like
 * an odometer, and hover reveals the absolute instant.
 */
export function RelativeTime({
  date,
  now,
  format = "long",
  live = true,
  tooltip = true,
  static: isStatic = false,
  className,
  ...props
}: RelativeTimeProps) {
  const reduced = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLTimeElement>(null);
  const inView = useInView(ref, { amount: 0.1 });

  // Stable per-mount fallback; demos pass `now` so renders stay deterministic.
  const [fallbackNow] = React.useState(() => new Date());
  const base = now ?? fallbackNow;
  const target = date ?? new Date(base.getTime() - 4 * MINUTE);

  const [elapsed, setElapsed] = React.useState(0);
  const effectiveNow = new Date(base.getTime() + elapsed);
  const { label, next } = relativePart(target, effectiveNow, format);
  const absolute = formatAbsoluteTime(target);

  // Wall-clock anchor from the first visible moment, so any update —
  // timer wake, tab return, scroll back into view — lands on true
  // elapsed time in one jump.
  const wasInViewRef = React.useRef(false);
  const wallStartRef = React.useRef<number | null>(null);
  React.useEffect(() => {
    const cameIntoView = inView && !wasInViewRef.current;
    wasInViewRef.current = inView;
    if (!live || !inView) return;
    wallStartRef.current ??= Date.now();
    const wallStart = wallStartRef.current;
    const update = () => setElapsed(Date.now() - wallStart);
    if (cameIntoView) update();
    // One precise wake-up at the next label flip; the state change
    // re-runs this effect, chaining the next wake-up.
    const id = window.setTimeout(
      () => {
        if (!document.hidden) update();
      },
      Math.max(1000, Math.min(next, 2_147_000_000)),
    );
    // Hidden tabs let the chain sleep; returning catches up in one jump.
    const onVisible = () => {
      if (!document.hidden) update();
    };
    document.addEventListener("visibilitychange", onVisible);
    return () => {
      window.clearTimeout(id);
      document.removeEventListener("visibilitychange", onVisible);
    };
  }, [live, inView, next, elapsed]);

  const roll = !isStatic && !reduced;

  const timeEl = (
    <time
      ref={ref}
      dateTime={target.toISOString()}
      title={tooltip ? undefined : absolute}
      className={cn(
        "inline-flex whitespace-nowrap tabular-nums",
        tooltip &&
          "cursor-default underline decoration-muted-foreground/30 decoration-dotted underline-offset-2 transition-[text-decoration-color] duration-150 ease-out hover:decoration-muted-foreground/60",
        className,
      )}
      {...props}
    >
      {/* Screen readers get the absolute instant; the rolling label is
          visual-only so live flips never announce. */}
      <span className="sr-only">{absolute}</span>
      <span aria-hidden className="relative inline-flex overflow-hidden">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={label}
            initial={roll ? { y: "100%", opacity: 0.25 } : false}
            animate={{ y: "0%", opacity: 1 }}
            exit={
              roll
                ? {
                    y: "-100%",
                    opacity: 0.25,
                    transition: { duration: 0.15, ease: EASE_EXIT },
                  }
                : { opacity: 0, transition: { duration: 0 } }
            }
            transition={{ duration: 0.22, ease: EASE_OUT }}
            className="inline-block"
          >
            {label}
          </motion.span>
        </AnimatePresence>
      </span>
    </time>
  );

  if (!tooltip) return timeEl;

  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger asChild>{timeEl}</TooltipTrigger>
        <TooltipContent>{absolute}</TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}