relative-time-lite
v1.5.0
Published
Relative time formatting in about a kilobyte, on top of the platform's Intl.RelativeTimeFormat — no bundled locale data, calendar-correct units, and a self-pacing live updater with a React hook.
Maintainers
Readme
relative-time-lite
Relative time formatting — "3 minutes ago", "in 2 months" — in 1.39 kB gzipped, with a live-updating React hook in the box.
npm install relative-time-liteEvery word comes from the platform's own Intl.RelativeTimeFormat. This package ships zero locale data: it decides which unit to say and when to say it again, and hands the wording to the runtime. Every locale your JavaScript engine knows already works, and adding the fiftieth one costs nothing.
Quick start
import { relativeTime } from 'relative-time-lite';
const published = Date.now() - 90_000;
relativeTime(published); // → '2 minutes ago'
relativeTime(Date.now() - 86_400_000); // → 'yesterday'
relativeTime(Date.now() + 3 * 86_400_000); // → 'in 3 days'
relativeTime(published, { locale: 'ru' }); // → '2 минуты назад'
relativeTime(published, { locale: 'de', style: 'short' }); // → 'vor 2 Min.'React, updating itself as the clock moves:
import { useRelativeTime } from 'relative-time-lite/react';
function PostedAt({ at }: { at: string }) {
return <time dateTime={at}>{useRelativeTime(at)}</time>;
}Why
| | |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No locale data | Intl.RelativeTimeFormat has been in every browser since 2020 and in Node since 12. Bundling 400 kB of translations to repeat what the platform already knows is the mistake this package avoids. |
| Calendar-correct | Months are 28–31 days and years are 365 or 366. Month and year distances come from the calendar, not from a 30.44-day average, so "1 month ago" means the same day last month. |
| Paced, not polled | A live timestamp sleeps until the moment its own words are due to change — a "2 hours ago" wakes when it becomes three, not on a half-hourly grid. No setInterval running for the life of the page. |
| One timer for the page | Every live timestamp shares a single setTimeout, aimed at whichever deadline comes first. A feed of two hundred comments arms one timer, not two hundred. |
| Quiet in the background | Updates stop while the tab is hidden and catch up the moment it comes back, on a window focus, or on a restore from the back/forward cache — so a timer a sleeping machine never fired cannot leave a stale timestamp on screen. |
| React-free core | React is only reachable through relative-time-lite/react, enforced by a build check. The root entry imports nothing. |
API
relativeTime(date, options?): string
import { relativeTime } from 'relative-time-lite';
relativeTime(new Date('2024-01-01'));
relativeTime(1704067200000);
relativeTime('2024-01-01T00:00:00Z');date may be a Date, epoch milliseconds, or any string Date.parse accepts — ISO 8601 is the only string format every runtime agrees on, so anything else is at the engine's discretion. Anything that would end up as NaN — an unparsable string, an invalid Date, undefined — throws a TypeError naming the package, rather than quietly formatting the words "NaN years ago". The React hooks are the exception: an unparsable date renders an empty string there, the same as a missing one.
Options
| Option | Type | Default | Description |
| ------------- | -------------------------------- | --------------- | ------------------------------------------------ |
| locale | string \| readonly string[] | runtime default | BCP 47 tag, or a fallback list. |
| style | 'long' \| 'short' \| 'narrow' | 'long' | Passed through to Intl.RelativeTimeFormat. |
| numeric | 'always' \| 'auto' | 'auto' | 'auto' prefers "yesterday" over "1 day ago". |
| justNowText | string | — | Wording for the justNowSeconds window. |
| format | (input) => string \| undefined | — | Your own wording, consulted first. |
| now | Date \| number \| string | Date.now() | Measure from a fixed point instead of the clock. |
Five more shape the unit itself, and are accepted everywhere a distance is measured — relativeTime, relativeTimeParts, selectUnit, the store and both hooks:
| Option | Type | Default | Description |
| ---------------- | -------------------- | ---------- | ---------------------------------------------------------- |
| minUnit | RelativeTimeUnit | 'second' | Finest unit to use. Anything smaller is said in this one. |
| maxUnit | RelativeTimeUnit | 'year' | Coarsest unit to use. Anything larger is said in this one. |
| rounding | 'round' \| 'floor' | 'round' | 'floor' truncates towards zero. |
| justNowSeconds | number | 0 | Distances shorter than this collapse to "now". |
| timeZone | string | runtime | IANA zone the calendar units are measured on. |
relativeTime(ts, { justNowSeconds: 45 }); // → 'now', for the first 45 seconds
relativeTime(ts, { justNowSeconds: 45, justNowText: 'just now' }); // → 'just now'
relativeTime(ts, { rounding: 'floor' }); // → '59 minutes ago', not '1 hour ago'
relativeTime(ts, { minUnit: 'minute' }); // → 'this minute', never seconds
relativeTime(ts, { maxUnit: 'day' }); // → '90 days ago', never months
relativeTime(ts, { maxUnit: 'quarter' }); // → '4 quarters ago', never years
relativeTime(ts, { timeZone: 'Europe/Warsaw' }); // → 'last month' on Warsaw's calendarWording of your own
format is consulted before anything else, and returning undefined hands the decision back — to justNowText if its window applies, and to Intl.RelativeTimeFormat otherwise. It receives the chosen unit and both instants in epoch milliseconds:
relativeTime(ts, {
locale: 'en',
format: ({ value, unit, date }) =>
unit === 'day' && value === -1 ? `yesterday at ${time(date)}` : undefined,
});That covers the cases the ladder has no vocabulary for — a "just now" without a justNowSeconds window, a language whose plural rules Intl gets wrong for your copy, an emoji. A format that answers every call replaces Intl.RelativeTimeFormat outright, which is also the way to run on a platform that does not have it.
Live stores and hooks pace themselves on the text your function returns, so a stable string means fewer wake-ups, not more.
maxUnit is also the way to hand off to an absolute date: cap the ladder, read the unit back from relativeTimeParts, and render a real date once it reaches the cap.
quarter sits between month and year, and is the one unit the ladder never picks on its own — "in 2 quarters" is not how most UIs read a date, so it is reachable only by naming it in minUnit or maxUnit.
timeZone is any IANA name, and it decides the calendar that months, quarters and years are measured against. Without one they are measured in whatever zone the runtime is in — which is the right answer in a browser and the wrong one on a server that renders for readers elsewhere. See Calendars and clocks.
numeric defaults to 'auto' rather than Intl's own 'always', because "yesterday" is what almost every UI wants. Pass 'always' to get the plain number back.
now turns the function into a plain distance between two instants, which is what tests and server rendering usually want:
relativeTime('2024-03-15T09:00:00Z', { now: '2024-03-15T12:00:00Z' }); // → '3 hours ago'relativeTimeParts(date, options?): { value, unit, text }
The same formatting with the decision that produced it, so markup that needs the unit does not have to derive it a second time:
import { relativeTimeParts } from 'relative-time-lite';
const { value, unit, text } = relativeTimeParts(comment.createdAt, { maxUnit: 'day' });
// → { value: -3, unit: 'minute', text: '3 minutes ago' }
unit === 'day' && value <= -30 ? absolute(comment.createdAt) : text;relativeTime is this function's .text, and takes exactly the same options.
useRelativeTime(date, options?): string
import { useRelativeTime } from 'relative-time-lite/react';
function Comment({ postedAt }: { postedAt: number }) {
const ago = useRelativeTime(postedAt, { locale: 'en' });
return <span>{ago}</span>;
}Same arguments as relativeTime, plus the store's own refreshMs and trackVisibility, plus hydrationText and serverNow for server rendering. The string keeps itself current and the component re-renders only when the words actually change — a comment from last Tuesday re-renders zero times over the next six hours, even though the hook wakes up to check.
Built on useSyncExternalStore, so the clock stays the source of truth, concurrent rendering sees a consistent value within a pass, and there is no useState/useEffect handshake to tear. The timer is torn down on unmount.
Inline arguments are safe: useRelativeTime(new Date(x), { locale: ['en'] }) does not rebuild the underlying store on every render — the date and options are reduced to primitives first. A date or an option that genuinely changes is pushed into the store the component already has, so the subscription survives and the component re-renders only if the words moved. (A format function is compared by identity like any other dependency: hoist it, or memoise it, to keep that true.)
A null, undefined or unparsable date renders an empty string rather than throwing, so a timestamp that may not have arrived yet — or one a row of API data got wrong — does not force a conditional hook and cannot take the tree down:
useRelativeTime(order.shippedAt ?? null); // → '' until it shipsreact is an optional peer dependency (>=18). The entry point is marked 'use client' for the Next.js App Router; the value it renders depends on the clock, so it cannot be a server component.
Hydration: hydrationText, serverNow, <RelativeTimeProvider>
Both hooks take two more options, and they exist for one reason: the clock moves between the server render and the hydration render, so a timestamp that said "3 hours ago" in the HTML may want to say "4 hours ago" by the time React reaches it — and React reports that as a hydration mismatch.
| Option | Type | Default | Description |
| --------------- | -------------------------- | ------- | --------------------------------------------------------------- |
| hydrationText | string | — | Rendered by the server pass and the hydration pass, live after. |
| serverNow | Date \| number \| string | — | The moment those two passes measure from. |
hydrationText puts a placeholder in the markup — an empty string, a dash, or the absolute date — and lets the browser fill in the live text on the frame after hydration:
useRelativeTime(postedAt, { locale: 'en', hydrationText: '' });serverNow keeps real words in the HTML instead, for a crawler or a reader with JavaScript off: name the moment the request was rendered and both passes measure from it, then the browser goes live.
useRelativeTime(postedAt, { locale: 'en', serverNow: requestTime });Neither freezes anything. now is the option that pins the clock for good; these two pin only the two renders that have to agree.
For a whole subtree — and to keep the request time out of every call site — <RelativeTimeProvider> supplies the defaults:
import { RelativeTimeProvider } from 'relative-time-lite/react';
export default function Page() {
return (
<RelativeTimeProvider now={Date.now()}>
<Feed />
</RelativeTimeProvider>
);
}Its now prop is the server's render moment — the serverNow above, not a frozen clock — and it also takes hydrationText. A hook that names either option ignores the provider entirely rather than merging with it, so a call can swap a subtree's placeholder for real text. Name both in one call and hydrationText is the one that renders.
useRelativeTimeParts(date, options?): { value, unit, text } | null
useRelativeTime with the decision behind the words, kept just as current:
function PostedAt({ at }: { at: string }) {
const { text, unit } = useRelativeTimeParts(at, { maxUnit: 'day' });
return unit === 'day' ? (
<time dateTime={at}>{absolute(at)}</time>
) : (
<time dateTime={at}>{text}</time>
);
}The object is replaced only when the text changes, so it is safe to compare by identity or pass into a useMemo. It is null — and only null — when the date is null or undefined; TypeScript narrows that away for a date that is certainly there.
createRelativeTimeStore(date, options?): RelativeTimeStore
The engine under the hook, for any other framework — or none:
import { createRelativeTimeStore } from 'relative-time-lite';
const store = createRelativeTimeStore(comment.createdAt, { locale: 'en' });
const node = document.querySelector('time')!;
node.textContent = store.getSnapshot();
const unsubscribe = store.subscribe(() => {
node.textContent = store.getSnapshot();
});
// later
unsubscribe();getSnapshot() is stable between ticks — repeated reads return the identical string until the text genuinely changes. getParts() is the same value as { value, unit, text }, and likewise keeps one object identity until the words move. subscribe() returns the unsubscribe; the last one clears the timer and detaches the shared visibilitychange listener.
Two options belong to the store alone:
| Option | Type | Default | Description |
| ----------------- | --------- | ------- | ----------------------------------------------------------------------- |
| refreshMs | number | — | Tick on a fixed interval instead of self-pacing, no faster than 250 ms. |
| trackVisibility | boolean | true | Suspend ticks while the document is hidden. |
A pinned now freezes the distance, so such a store schedules nothing and watches nothing — it is a formatted string with a subscribe that never fires.
setDate(date) and setOptions(options) move a store that is already subscribed, so a list that re-orders or a locale switch does not have to tear down and rebuild one:
store.setDate(comment.editedAt);
store.setOptions({ locale: 'ru', maxUnit: 'day' });Both keep every subscription, re-pace the timer, and notify only if the words actually changed. setOptions replaces the option set rather than patching it, so pass everything you want kept.
Every live store shares one setTimeout, always aimed at the earliest deadline any of them is waiting for. Two hundred stores are two hundred entries in a set and one timer; the last unsubscribe clears it.
selectUnit(fromMs, toMs, options?): { value, unit }
The pure unit picker, exported for anyone who wants the decision without the formatting — a custom formatter, <time> tooltips, tests.
import { selectUnit } from 'relative-time-lite';
selectUnit(now, now - 90_000); // → { value: -2, unit: 'minute' }
selectUnit(now, now + 86_400_000); // → { value: 1, unit: 'day' }No clock access, no side effects. value is negative in the past and positive in the future, ready to hand straight to Intl.RelativeTimeFormat.format. It takes the four ladder options above, and throws a TypeError on a timestamp that is not finite rather than returning a NaN nobody checks.
How units are chosen
Each threshold is checked on the rounded value, so the switch happens at the halfway point rather than a unit late: 59.5 seconds is already "1 minute ago", not "59 seconds ago". Rounding ties break away from zero, so the past and the future flip at exactly the same distance.
| Range | Unit |
| ------------------------------ | -------- |
| under 60 rounded seconds | second |
| under 60 rounded minutes | minute |
| under 24 rounded hours | hour |
| under 7 rounded days | day |
| up to one whole calendar month | week |
| under 12 calendar months | month |
| beyond that | year |
Weeks fill the stretch between a week and a month, which caps them at 4 — you will never see "5 weeks ago" turn up next to "last month".
There is no row for quarter: it is only ever reached by naming it in minUnit or maxUnit, and the unclamped ladder steps from months straight to years.
minUnit and maxUnit clamp this ladder from either end: the distance is then expressed in the nearest allowed unit, however large or small the number gets. rounding: 'floor' moves every threshold from the halfway point to the whole one, and justNowSeconds puts a flat "now" in front of the whole thing.
Calendars and clocks
Seconds through days are measured in elapsed time. A day is 24 real hours, so noon-to-noon across a spring-forward reads "23 hours ago" — which is what actually elapsed.
Months, quarters and years are measured on the calendar:
- Feb 1 → Mar 1 is one month, whether February had 28 days or 29.
- Jan 31 + one month is Feb 28 (or 29), the standard clamp, so that pair reads "last month".
- A year is a year across two DST transitions and any number of leap days.
Which calendar is the question timeZone answers. Left out, it is the runtime's own zone — the reader's, in a browser, which is what you want. Passed, it is that zone's calendar, offset and DST rules at each instant:
const from = '2024-01-30T23:30:00Z'; // 00:30 on Jan 31 in Warsaw
const to = '2024-02-28T23:45:00Z'; // 00:45 on Feb 29 in Warsaw
relativeTime(from, { locale: 'en', now: to }); // → '4 weeks ago' on a UTC server
relativeTime(from, { locale: 'en', now: to, timeZone: 'Europe/Warsaw' }); // → 'last month'That is the fix for a server and a browser disagreeing about where a month boundary falls: name the zone you are rendering for and both sides measure against the same calendar. Seconds through days are elapsed time and never move, so timeZone has nothing to change about them.
Bundle size
Measured gzipped, with size-limit:
| Import | Size |
| ------------------------------------------- | ------- |
| import { selectUnit } | 909 B |
| import { relativeTime } | 1.39 kB |
| the whole root entry | 2.38 kB |
| relative-time-lite/react (React excluded) | 2.79 kB |
The package is side-effect free and every export is tree-shakeable, so importing only relativeTime leaves the auto-update engine out of your bundle entirely.
Notes
Server rendering. relativeTime and the hook's server snapshot are the same computation, but the clock moves between the render and the hydration. The hooks answer that with hydrationText and serverNow; outside React, pass the same now to both sides.
Time zones. Without a timeZone, calendar units are measured in whatever zone the runtime is in, so a server running in UTC and a browser in Europe/Warsaw can disagree about where a month boundary falls — a second, quieter source of hydration mismatch on top of the moving clock above. Pass the same timeZone on both sides and it goes away.
Why no WeakRef. Letting the garbage collector decide when a visible timestamp stops updating trades a deterministic leak for a nondeterministic bug. The store instead ties its lifetime to explicit subscription: the timer exists only while a listener does, and a single shared set of visibilitychange, focus and pageshow listeners serves every store on the page, attached with the first subscription and removed with the last.
Requirements
Any runtime with Intl.RelativeTimeFormat: Node 12+, and every browser since early 2020. The timeZone option additionally wants timeZoneName: 'longOffset' on Intl.DateTimeFormat, which means Node 18+, Chrome 95+, Safari 15.4+ and Firefox 91+; nothing else in the package touches it. A runtime without it throws a TypeError naming the package on the first call that needs wording — load a polyfill, or pass a format function and the platform formatter is never reached. Node builds without full ICU (--with-intl=small-icu) only carry English — use full-icu if you need more.
License
MIT © Pavel Lazarchuk
