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.
npx shadcn@latest add @paragon/relative-timeAlso installs: tooltip
"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>
);
}