an instant said from now, "4m ago" or "in 3d", ticking only as often as its words change
npx shadcn add @ag/relative-timeAlready installed? Preview an update
npx shadcn add @ag/relative-time --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
RelativeTime, an instant said from now in a native<time>: "just now", "12s ago", "4m ago", "in 3d", and pastdateAfterdays (7) its date, with its year when it is not this one.dateTimeholds the ISO instant and the title the whole date in the reader's zone. The reader's clock ticks only while the label is on screen in a visible tab, and only when its words change (every second, then every minute, and so on).precision="minute"says "just now" for the first minute;nowsays it from a given instant with no clock. The words are framework-free inlib/relative-time-core(relativeText,nextChange,dateText,fullText), usable on a server.
also called time ago, timeago, relative date
shelf data & status
uses use-on-screen
used by data-view, deployment
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Readout, ReadoutRow } from "@/registry/base-nova/ui/readout"
import { RelativeTime } from "@/registry/base-nova/ui/relative-time"
// #region clock
const S = 1000
const M = 60 * S
const H = 60 * M
const D = 24 * H
/** The reader's clock when the page mounted: the instants below are ages from it.
* Null on the server, whose clock is not the reader's. */
function useMountedAt(): number | null {
const [at, setAt] = React.useState<number | null>(null)
React.useEffect(() => setAt(Date.now()), [])
return at
}
// #endregion
// #region feed
/** Events at every age a feed holds: each label ticks at its own pace, the fresh
* ones every second, the older every minute or hour, a date not at all. */
function Feed() {
const t = useMountedAt()
if (t === null) return null
return (
<Readout className="w-72">
<ReadoutRow label="opened">
<RelativeTime at={t} />
</ReadoutRow>
<ReadoutRow label="last sync">
<RelativeTime at={t - 42 * S} />
</ReadoutRow>
<ReadoutRow label="deployed">
<RelativeTime at={t - 4 * M - 10 * S} />
</ReadoutRow>
<ReadoutRow label="backup">
<RelativeTime at={t - 11 * H} />
</ReadoutRow>
<ReadoutRow label="invoice sent">
<RelativeTime at={t - 2 * D} />
</ReadoutRow>
<ReadoutRow label="signed up">
<RelativeTime at={t - 40 * D} />
</ReadoutRow>
<ReadoutRow label="first commit">
<RelativeTime at={t - 500 * D} />
</ReadoutRow>
</Readout>
)
}
// #endregion
// #region ahead
/** Instants still to come count down, and pass through "now" to "just now". */
function Ahead() {
const t = useMountedAt()
if (t === null) return null
return (
<Readout className="w-72">
<ReadoutRow label="next poll">
<RelativeTime at={t + 9 * S} />
</ReadoutRow>
<ReadoutRow label="certificate renews">
<RelativeTime at={t + 3 * D + 2 * H} />
</ReadoutRow>
<ReadoutRow label="trial ends">
<RelativeTime at={t + 30 * D} />
</ReadoutRow>
</Readout>
)
}
// #endregion
// #region touched
/** A list where seconds are noise: under a minute it is "just now", then minutes,
* and past three days the date (deployment's list says times this way). */
function Touched() {
const [at, setAt] = React.useState<number | null>(null)
React.useEffect(() => setAt(Date.now()), [])
return (
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => setAt(Date.now())}>
touch
</Button>
<span className="text-[13px] text-current/60">
edited{" "}
{at !== null && (
<RelativeTime at={at} precision="minute" dateAfter={3} />
)}
</span>
</div>
)
}
// #endregion
// #region still
/** Said from a given instant, a replay's or a still's: no clock is kept, so the
* server and the browser draw the same words. */
const READ_AT = Date.UTC(2026, 9, 12, 21, 40)
function Still() {
return (
<Readout className="w-72">
<ReadoutRow label="pushed">
<RelativeTime at={READ_AT - 7 * M} now={READ_AT} />
</ReadoutRow>
<ReadoutRow label="reviewed">
<RelativeTime at={READ_AT - 5 * D} now={READ_AT} />
</ReadoutRow>
<ReadoutRow label="released">
<RelativeTime
at={READ_AT - 75 * D}
now={READ_AT}
dateAfter={Number.POSITIVE_INFINITY}
/>
</ReadoutRow>
</Readout>
)
}
// #endregion
export default function RelativeTimeDemo() {
return (
<div className="space-y-8">
<Sample
name="feed"
glance
title="every age, ticking at its pace"
description="the fresh ones count seconds, the older minutes and hours, past a week the date, with its year when it is not this one. hover one for the whole date."
with="clock feed"
>
<Feed />
</Sample>
<Sample name="ahead" title="instants still to come" with="clock ahead">
<Ahead />
</Sample>
<Sample
name="minutes"
title="seconds are noise: minutes"
description="precision minute: just now for the first minute; dateAfter 3: the date from the third day."
with="touched"
>
<Touched />
</Sample>
<Sample
name="still"
title="said from a given now"
description="a replay or a still passes its own now: no clock, the same words on the server and in the browser. dateAfter Infinity counts on in weeks, months and years."
with="still"
>
<Still />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
fullText,
nextChange,
type RelativeOptions,
relativeText,
} from "@/registry/base-nova/lib/relative-time-core"
import { useOnScreen } from "@/registry/base-nova/ui/use-on-screen"
// AN INSTANT SAID FROM NOW, in a native <time>: "4m ago", "in 3d", "just now", past a
// week its date. The machine-readable instant is `dateTime` (ISO), the whole date with
// its time and zone is the title, the words are lib/relative-time-core's.
//
// <RelativeTime at={deploy.created} />
// <RelativeTime at={job.due} now={replayClock} precision="minute" dateAfter={3} />
//
// THE CLOCK is the reader's, read after mount: the server's would disagree with the
// reader's, and the two renders with each other, so until then the label is empty.
// It ticks only while the label is on screen in a visible tab (use-on-screen), and
// waits exactly until the words next change (`nextChange`): once a second while it
// counts seconds, once a minute while it counts minutes, not at all on a date until
// the year turns. Back on screen, it reads the clock at once. Given `now` (a page's
// own clock, a replay's, a still), it says the instant from there and keeps no clock.
//
// It draws in the words' voice where it stands (tabular figures, never wrapping);
// `className` gives it another.
/** The longest wait a browser timer holds (2^31 - 1 ms, about 24.8 days); a longer
* one fires at once. A wait past it is taken in steps. */
const LONGEST_TIMER = 2 ** 31 - 1
/** Whether this render is the browser's: false on the server and while hydrating. */
const subscribeNothing = () => () => {}
const yes = () => true
const no = () => false
export type RelativeTimeProps = Omit<
React.ComponentProps<"time">,
"children" | "dateTime"
> &
RelativeOptions & {
/** The instant, ms since the epoch. */
at: number
/** The instant it is said from. Default the reader's clock, ticking. */
now?: number
}
export function RelativeTime({
at,
now,
precision,
dateAfter,
className,
ref,
...props
}: RelativeTimeProps) {
if (!Number.isFinite(at))
throw new Error(`RelativeTime: at is no instant (${at})`)
const [element, setElement] = React.useState<HTMLTimeElement | null>(null)
const here = React.useCallback(
(node: HTMLTimeElement | null) => {
setElement(node)
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref],
)
const shown = useOnScreen(element)
const browser = React.useSyncExternalStore(subscribeNothing, yes, no)
const [clock, setClock] = React.useState<number | null>(null)
React.useEffect(() => {
if (now !== undefined) return
let timer = 0
const tick = () => {
const t = Date.now()
setClock(t)
if (!shown) return
const wait = nextChange(at, t, { precision, dateAfter })
if (wait !== null)
timer = window.setTimeout(tick, Math.min(wait, LONGEST_TIMER))
}
tick()
return () => window.clearTimeout(timer)
}, [at, now, shown, precision, dateAfter])
const from = now ?? clock
return (
<time
ref={here}
data-slot="relative-time"
dateTime={new Date(at).toISOString()}
// In the reader's zone, so only in the browser: the server's is another's.
title={browser ? fullText(at) : undefined}
className={cn("whitespace-nowrap tabular-nums", className)}
{...props}
>
{from === null ? "" : relativeText(at, from, { precision, dateAfter })}
</time>
)
}// AN INSTANT SAID FROM NOW: "just now", "12s ago", "4m ago", "in 3d", and past a
// reach its date ("Oct 8", with its year when it is not now's). Framework-free, so a
// server, an email or a log line says it the same way the label does.
//
// relativeText(at, now) the words
// nextChange(at, now) ms until the words change, or null for never
//
// Instants are ms since the epoch. A count is floored ("4m ago" from 4:00 to 4:59),
// ahead as behind ("in 4m" from 4:59 down to 4:00), in the largest unit that fits
// (seconds, minutes, hours, days, weeks, months of 30 days, years of 365). Under one
// of the finest unit said (`precision`) it is "just now", or "now" ahead. Past
// `dateAfter` days either way it is the date, in the reader's time zone.
//
// `nextChange` is what makes a label's clock follow the age: the words change once a
// second while they count seconds, once a minute while they count minutes, and a date
// changes only when the year turns. A clock that waits exactly that long ticks no
// more often than the words move, and never late.
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** The units a count is said in, finest first. */
const UNITS = [
{ name: "second", short: "s", ms: SECOND },
{ name: "minute", short: "m", ms: MINUTE },
{ name: "hour", short: "h", ms: HOUR },
{ name: "day", short: "d", ms: DAY },
{ name: "week", short: "w", ms: 7 * DAY },
{ name: "month", short: "mo", ms: 30 * DAY },
{ name: "year", short: "y", ms: 365 * DAY },
] as const
type Unit = (typeof UNITS)[number]
const unitAt = (i: number): Unit => {
const unit = UNITS[i]
if (!unit) throw new Error(`relative-time: no unit ${i}`)
return unit
}
export interface RelativeOptions {
/** The finest unit counted: under one of it, "just now" ("now" ahead). Default
* `second`; `minute` for a list where seconds are noise. */
precision?: "second" | "minute"
/** Days from now, either way, past which the instant is said as its date. Default
* 7; `Infinity` counts on in weeks, months and years. */
dateAfter?: number
}
const MONTH_DAY = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
})
const MONTH_DAY_YEAR = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
const FULL = new Intl.DateTimeFormat("en-US", {
dateStyle: "full",
timeStyle: "long",
})
function settle({ precision = "second", dateAfter = 7 }: RelativeOptions) {
if (!(dateAfter > 0))
throw new Error(
`relative-time: dateAfter is days above 0, got ${dateAfter}`,
)
const finest = UNITS.findIndex((u) => u.name === precision)
if (finest < 0) throw new Error(`relative-time: no precision "${precision}"`)
return { finest, reach: dateAfter * DAY }
}
function assertInstant(what: string, t: number) {
if (!Number.isFinite(t))
throw new Error(`relative-time: ${what} is no instant (${t})`)
}
/** The largest unit from `finest` on that fits in `far` ms (`far` at least one of the
* finest). */
function unitFor(far: number, finest: number): number {
let u = finest
while (u + 1 < UNITS.length && unitAt(u + 1).ms <= far) u++
return u
}
/** The instant as a date: "Oct 8", or "Oct 8, 2025" when its year is not now's. */
export function dateText(at: number, now: number): string {
const date = new Date(at)
return new Date(now).getFullYear() === date.getFullYear()
? MONTH_DAY.format(date)
: MONTH_DAY_YEAR.format(date)
}
/** The instant in full, with its time and zone: what a title shows. */
export function fullText(at: number): string {
assertInstant("at", at)
return FULL.format(new Date(at))
}
/** The instant as it is said from `now`. */
export function relativeText(
at: number,
now: number,
options: RelativeOptions = {},
): string {
assertInstant("at", at)
assertInstant("now", now)
const { finest, reach } = settle(options)
const ago = now - at
const far = Math.abs(ago)
if (far >= reach) return dateText(at, now)
if (far < unitAt(finest).ms) return ago >= 0 ? "just now" : "now"
const unit = unitAt(unitFor(far, finest))
const count = `${Math.floor(far / unit.ms)}${unit.short}`
return ago >= 0 ? `${count} ago` : `in ${count}`
}
/** The start of the year after `now`'s, in the reader's time zone. */
const nextYear = (now: number) =>
new Date(new Date(now).getFullYear() + 1, 0, 1).getTime()
/** Ms from `now` until `relativeText(at, …)` says something else; null when it never
* will (a date that already shows its year, behind). */
export function nextChange(
at: number,
now: number,
options: RelativeOptions = {},
): number | null {
assertInstant("at", at)
assertInstant("now", now)
const { finest, reach } = settle(options)
const ago = now - at
const far = Math.abs(ago)
const sameYear = new Date(now).getFullYear() === new Date(at).getFullYear()
if (ago >= 0) {
// Behind: the count grows. A date changes once, when the year turns under it.
if (far >= reach) return sameYear ? nextYear(now) - now : null
const fine = unitAt(finest).ms
if (far < fine) return fine - far
const u = unitFor(far, finest)
const unit = unitAt(u)
const larger = UNITS[u + 1]
return (
Math.min(
(Math.floor(far / unit.ms) + 1) * unit.ms,
larger ? larger.ms : Number.POSITIVE_INFINITY,
reach,
) - far
)
}
// Ahead: the count shrinks, and becomes "just now" as the instant passes.
if (far >= reach) {
// Counted once inside the reach; before that, its year shows until now's year
// is its own.
const counted = far - reach + 1
const itsYear = new Date(new Date(at).getFullYear(), 0, 1).getTime()
return sameYear ? counted : Math.min(counted, itsYear - now)
}
const fine = unitAt(finest).ms
if (far < fine) return far
const unit = unitAt(unitFor(far, finest))
return far - Math.floor(far / unit.ms) * unit.ms + 1
}