Dates & times
One Intl-based module for every date, time, duration, due label and day grouping — plus RelativeTime, DateTime, Duration and DueLabel. No date library.
- Status
- Since
0.1.0- Accessibility pattern
- native time element
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Dates & Times before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify relative-timeInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/relative-timeRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/date-time, @vegastack/tooltip, @vegastack/truncated-text.
Usage
For stable relative text from the first render, pass a request timestamp with the timezone:
<TimeZoneProvider timeZone={zone} referenceNow={requestNow}>. Serialize the same numeric
requestNow into the client tree. Uncontrolled timestamps then share one live clock; passing
now directly remains a controlled clock. Without a request reference, the server renders a
clock-free absolute date ("Sep 25", "Sep 25 · 2:30 PM", "Due Sep 30"), so crawlers and no-JS HTML
keep the date; the browser then switches to the relative words.
import {
DateTime,
DueLabel,
Duration,
RelativeTime,
} from "@/components/ui/relative-time";
import { formatDate, formatRelative, groupByDay } from "@/lib/date-time";
<RelativeTime date={row.updatedAt} format="minimal" />; // "2m"
<DateTime date={task.createdAt} />; // "Sep 25"
<DueLabel date={task.dueAt} />; // "Overdue 2d"
formatRelative(row.updatedAt, { timeZone }); // plain string, server or clientThe formatting functions live in @/lib/date-time and are plain — call them in server components,
route handlers, emails and client code alike. The components wrap them in a semantic <time> and
add the absolute time in a hover tooltip ("Sep 25, 2026 · 2:30 PM IST").
Shared rules
- The year shows only when it is not the current year.
- 12-hour clock with no leading zero: "9:04 AM".
- Minimal units
smhdwmoy, no space before the unit. - Calendar days use the viewer's time zone (
timeZoneoption on every function). - Anything under 45 seconds reads "now".
- Built on
Intl, locale-ready; English for now. - Set in the body font with proportional figures, like the text around them. Tabular figures are for live timers (the media player's clock), not for dates.
Where to use what
| Surface | Use | Reads |
|---|---|---|
| Table columns (updated, last activity) | formatRelative / <RelativeTime format="minimal"> | now · 2m · 3h · 5d |
| Table columns (created, due, start date) | formatDate / <DateTime> | Today · Mon · Sep 25 |
| Detail pages, audit rows | formatDateTime / <DateTime variant="datetime"> | Sep 25 · 2:30 PM |
| Hover tooltips | formatTooltip (built into every component) | Sep 25, 2026 · 2:30 PM IST |
| Notifications, feeds | formatRelative + groupByDay headers | 2m under "Today" |
| Sentences | formatRelative({ style: "suffix" }), separator: "comma" | 2m ago · Sep 25, 2:30 PM |
| Due chips | formatDueLabel / <DueLabel> | Overdue 2d · Due today · Due Sep 30 |
| Durations (meetings, audio, SLAs) | formatDuration / <Duration>; clock for players | 1h 15m · 1:15:04 |
| Durations in sentences and emails | formatDuration({ style: "long" }) | 1 hour 15 minutes · 7 days |
| Page headers, greetings | formatLongDate; hourOfDay for the greeting | Thursday, September 24 |
| Schedules, events | formatDateRange, formatTimeOfDay | Sep 25 · 2–3:30 PM |
| Grouped lists (inbox, activity) | groupByDay | Today / Yesterday / This week / Last week / Earlier |
| Stored calendar days (a date field) | formatCalendarDate; parseCalendarDay / toCalendarDay | Oct 8, 2026 — the same day in every zone |
| API / JSON | ISO strings — never formatted text | 2026-09-25T09:00:00.000Z |
Anatomy
Dates & times exports several parts. Every exported part, with the data-slot it renders
(generated from the canonical source):
Examples
formatRelative
now · 2m · 3h · 5d · 1w · 2mo · 1y · in 2d. style: "suffix" gives "2m ago"; style: "long"
gives "2 minutes ago".
30s agonow2 min ago2m3 h ago3h5 days ago5d9 days ago1w70 days ago2mo400 days ago1yin 2 daysin 2dsuffix2m agolong2 minutes agoformatDuration
Input in seconds (or unit: "milliseconds"). At most two units; seconds drop once minutes exist.
clock: true reads "1:15:04"; style: "long" spells the units for sentences and emails —
"1 hour 15 minutes", "7 days".
4242s1502m45001h 15m36001h1836002d 3h4504, clock1:15:044500, long1 hour 15 minutesformatDate and formatTimeOfDay
Today · Tomorrow · Yesterday · a weekday within ±6 days ("Mon", "Last Fri") · "Sep 25" ·
"Dec 20, 2026". looseFuture: true turns far-future dates into "in 2 weeks"; absolute: true
always prints the date.
formatLongDate is the page-header date — "Thursday, September 24", with the year outside the
current year; locale: "en-IN" (or en-GB) reads "Thursday, 24 September". hourOfDay returns
the hour (0–23) on the viewer's clock, for a greeting or a working-hours check.
todayToday+1 dayTomorrow-1 dayYesterday+3 daysSun-4 daysLast Sun+20 daysFeb 4last yearDec 6, 2025+20 days, looseFuturein 3 weeksformatTimeOfDay5:30 PMformatLongDateThursday, January 15formatLongDate, en-INThursday, 15 JanuaryformatDateTime and formatDateRange
formatDateTime joins the day and time with a middle dot ("Today · 2:30 PM"); use
separator: "comma" inside sentences. formatDateRange collapses what the ends share.
todayToday · 5:30 PM-10 daysJan 5 · 5:30 PMlast yearDec 6, 2025 · 5:30 PMcommaJan 5, 5:30 PMrange, same dayJan 15 · 5:30–7 PMrange, same monthJan 17–20range, two monthsJan 25 – Feb 4range, two yearsDec 26, 2025 – Jan 5, 2026formatDueLabel
Returns { label, tone } — tone is overdue, soon (today/tomorrow) or normal.
-2 daysOverdue 2d (overdue)0 daysDue today (soon)+1 daysDue tomorrow (soon)+3 daysDue in 3d (normal)+9 daysDue Jan 24 (normal)Calendar days
A day stored without a time ("2026-10-08": a due date, a date field) is not an instant, so it
never goes through a zone. formatCalendarDate(day, { locale, format }) writes the day it names —
short "Oct 8, 2026", long "October 8, 2026", numeric "10/8/2026" — for a reader in UTC−8 and
in UTC+13 alike, and "" for an invalid day. parseCalendarDay(day) gives the local-midnight
Date a DatePicker takes, and toCalendarDay(date) turns the picked Date back into the day,
with no shift; toCalendarDay(instant, { timeZone }) is the day an instant falls on in a zone
("today" for a viewer).
formatCalendarDate("2026-10-08"); // "Oct 8, 2026"
toCalendarDay(parseCalendarDay("2026-10-08")!); // "2026-10-08"
toCalendarDay(new Date(), { timeZone }); // today, for this viewergroupByDay
Buckets items into Today / Yesterday / This week / Last week / Earlier (weeks start Monday; empty
groups are dropped). Each group has a stable key and a label.
Components
<RelativeTime>, <DateTime> and <DueLabel> follow the shared live clock; explicit
RelativeTime.now and DateTime.options.now stay controlled, and RelativeTime.refresh={false}
freezes at the moment it mounts. These date components show the absolute time in a hover tooltip unless
title={false}. <Duration> formats the supplied length without a live clock or absolute-time tooltip.
Time zone
Day boundaries follow the viewer. Write the browser zone to a tz cookie, read it on the server
with the org zone from app config as the fallback, and provide it to the tree:
// app/layout.tsx (server)
import { cookies } from "next/headers";
import {
TimeZoneProvider,
TimeZoneScript,
} from "@/components/ui/relative-time";
import { TIME_ZONE_COOKIE, getTimeZone } from "@/lib/date-time";
import { config } from "@/config";
const timeZone = getTimeZone(
(await cookies()).get(TIME_ZONE_COOKIE)?.value,
config.orgTimeZone,
);
<body>
<TimeZoneScript />
<TimeZoneProvider timeZone={timeZone} referenceNow={Date.now()}>
{children}
</TimeZoneProvider>
</body>;TimeZoneScript writes the cookie on load and again when the tab becomes visible, so a changed
zone reaches the next request. Client code reads the zone with useTimeZone(); server code passes
timeZone to each function.
RelativeTime renders a semantic <time dateTime> element and self-updates on a
timer while the date is recent, with the exact date and time in the hover. It uses the
platform-native Intl APIs — no date-fns or other date dependency.
RelativeTime formats
Past and future instants, in the house compact form: now under a minute, then 19m ago,
3h ago, 2d ago, 3w ago, 5mo ago, 1y ago; future dates read in 2d. No unit carries a
period — the old Intl short style (19 min. ago) is gone.
Format variants
format picks the form of the ago label: suffix (the default, 19m ago), minimal for dense
tables and chips (19m), and long for sentences (19 minutes ago). The deprecated unitStyle
maps long to format="long" and everything else to the default.
Modes
mode="ago" (default) is duration-relative — "2h ago", "in 3d".
mode="day" is calendar-relative — "today", "yesterday", "tomorrow", and an
absolute date ("March 15", "March 15, 2025") once a date is further out.
States
By default the absolute date-time is revealed in a Tooltip on hover or focus. Pass a
string to title for a custom label, or title={false} to drop the tooltip.
Tooltip delay
tooltipDelay (ms) sets how long the pointer must hover before the absolute-date
tooltip opens. It defaults to the app-wide tooltip delay (TIMINGS.tooltipOpenDelayMs, 250ms),
so it matches every other tooltip. Ignored when title={false}.
Localization
locale localizes what Intl words: the mode="day" labels and dates and format="long". The
compact ago form (2h ago) is the house form everywhere, so a dense column reads the same in
every locale. Without locale it formats in DEFAULT_LOCALE (en-US), never the runtime's: a
server under another LANG then renders the same text as the browser, so the page hydrates
without a mismatch. Pass the reader's locale explicitly to localize.
Standalone label
Inside a sentence the relative words read lower-case ("due today"). A label that stands on its own
takes capitalize ("Today"). With title={false} it has no tooltip, so it is plain inline text at
the line's height rather than a 24px pointer target.
Fixed time zone
timeZone (an IANA name) decides which calendar day an instant falls on, and formats the server's
first render and the tooltip in the same zone — so a server in UTC and a reader in IST agree on what
"today" is. Omit it to follow the runtime's zone.
Day with time
In mode="day", withTime appends the time of day ("Today, 10:00 AM"). formatOptions shapes
the absolute date shown beyond yesterday and tomorrow — { day: "numeric", month: "short" } reads
"6 Jan" in en-IN — and the year is added only when the date is in another year.
Live updates
When now is left unset, timestamps subscribe to one shared ten-second clock, and each one
re-renders only when its own label can change: every tick within an hour of now, once a minute
within a day, and on quarter-hour boundaries beyond that (every real zone offset is a multiple of
15 minutes, so day words still flip at the viewer's midnight). A time of day and an absolute date
never subscribe. No per-row timers are allocated. The example below is intentionally live — it seeds a timestamp
30s in the past and climbs as you watch. Pass refresh={false} to opt out.
Application groups, due badges and date-picker presets can share that same clock through
useDateTimeNow(), exported by this registry item. It returns epoch milliseconds, or undefined
until hydration when no request reference was provided. With TimeZoneProvider.referenceNow,
server output and the hydration render receive the same reference. Keep a placeholder for a
missing reference; do not replace it with a separate Date.now() during rendering.
import { DateTime, useDateTimeNow } from "@/components/ui/relative-time";
export function CurrentTime() {
const nowMs = useDateTimeNow();
return nowMs === undefined ? (
<span>…</span>
) : (
<DateTime date={nowMs} variant="time" />
);
}Format the instant in the viewer's effective zone. The hook and uncontrolled RelativeTime
instances share one timer; a RelativeTime now={...} remains controlled.
Determinism
The displayed value depends on the current time. For tests, SSR snapshots, or
Storybook, pass a fixed now (epoch ms) to render deterministically — this also
disables the refresh timer.
<RelativeTime date={ts} now={Date.UTC(2026, 0, 15, 12, 0, 0)} refresh={false} />Playground
Switch the formatting mode and the instant being rendered, then copy the generated JSX.
<RelativeTime date={new Date(Date.now() - 5 * 60_000)} />API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
date* | string | number | Date | — | The instant to render, relative to now. Accepts a Date, an ISO string,
or an epoch-millisecond number. |
capitalize | boolean | false | Upper-case the label's first letter — "Today", "Yesterday", "Now" — for a label that
stands on its own rather than inside a sentence. |
focusable | boolean | — | Whether the timestamp becomes a tab stop so keyboard users can open the
absolute-date Tooltip. Defaults to true standalone and to whatever a
TruncationFocusProvider sets — false under a grid or list host, where 50
rows would otherwise mean 50 extra tab stops on top of that host's own roving
focus (audit B2-04 / decision D9). The machine-readable dateTime attribute is
unaffected either way. |
format | "long" | "minimal" | "suffix" | 'suffix' | The ago label's form, from formatRelative: suffix ("19m ago", the house default),
minimal ("19m", for dense tables and chips) or long ("19 minutes ago", for sentences).
Every form reads "now" under a minute and carries no periods. Ignored in mode="day". |
formatOptions | Intl.DateTimeFormatOptions | { month: "long", day: "numeric" } | Intl.DateTimeFormat options for the absolute date mode="day" shows beyond
yesterday/tomorrow — e.g. { day: "numeric", month: "short" } for "22 Sep" in en-IN.
The year is added when the date is not in the current year, unless you set year yourself. |
locale | string | string[] | "en-US" | BCP-47 locale(s) for Intl formatting: mode="day"'s words and dates, format="long" and
the tooltip. The compact ago form ("19m ago") is the house form in every locale. Defaults to
DEFAULT_LOCALE (en-US), never the runtime's, so the server and the browser render the same
text. |
mode | "ago" | "day" | 'ago' | Formatting mode.
- ago: duration-relative in the house compact form — "now", "19m ago", "3h ago",
"2d ago", "3w ago", "5mo ago", "1y ago", "in 2d".
- day: calendar-relative — "today", "yesterday", else an absolute date. |
now | number | Date.now() | Reference instant the relative string is measured against, as epoch ms.
Defaults to the live clock (Date.now()); pass a fixed value to render
deterministically (tests, SSR snapshots, storybook). |
refresh | boolean | true | Auto-refresh from the shared ten-second clock. No per-row timer is created.
Ignored when now is provided. |
timeZone | string | — | IANA time zone ("Asia/Kolkata") that decides the calendar day and every formatted date and
time — the day label, the server's first render and the tooltip — so a server in UTC and a
reader in IST agree on what "today" is. Defaults to the runtime's zone. |
title | string | boolean | true | Reveal the absolute date/time in a Tooltip on hover/focus.
- true: a localized full date-time ("March 15, 2025, 2:30 PM").
- a string: your own label.
- false: no tooltip. |
tooltipDelay | number | TIMINGS.tooltipOpenDelayMs | How long to wait (ms) before the tooltip opens on hover. Defaults to the
app-wide TIMINGS.tooltipOpenDelayMs. Ignored when title is false. |
unitStyle | "long" | "narrow" | "short" | — | Deprecated. |
withTime | boolean | false | In mode="day", append the time of day: "Today, 11:30 AM", or the absolute date with its
time. Ignored in mode="ago". |
Data attributes and CSS variables on RelativeTime
| Attribute | Values |
|---|---|
data-mode | mirrors a prop or state value |
data-slot | "relative-time" |
Server rendering
Pass a serialized request timestamp as TimeZoneProvider.referenceNow to render relative text
identically on the server and the hydration render. The shared clock then updates live labels.
Without that reference, the server renders the clock-free absolute date and the browser
replaces it with the relative label. now remains a controlled clock, and refresh={false}
freezes at mount time, never at an older request reference.
DateTime and DueLabel use the same request/live reference, so calendar words and due tones
update across midnight in the effective zone. DateTime.options.now is an explicit controlled
reference; absolute, relativeDay and other formatting choices retain their meanings.
Without a request reference, they render the clock-free absolute date (a time of day and an
absolute date are exact on the server). These components do not suppress hydration mismatches; absolute tooltip labels retain their full date,
time and zone.
Accessibility
- Renders a semantic
<time dateTime="…">so the machine-readable ISO timestamp is always present for assistive tech, even as the visible text updates. - When the absolute-date Tooltip is enabled, the
<time>is made focusable (tabindex="0") so keyboard users can reveal it;:focus-visibleshows a 2px ring (outline-ring) — neveroutline: nonealone. - Color is inherited from context — the component never encodes meaning in color, so the relative string stands on its own.
| Key | Action |
|---|---|
| Tab | Move focus to the timestamp (when the tooltip is enabled). |
| Esc | Dismiss the open absolute-date tooltip. |
focusable controls whether the timestamp takes a tab stop to open its absolute-date
Tooltip. It defaults to true standalone, and to whatever an enclosing
TruncationFocusProvider sets — false for a grid or list host, where 50 rows would
otherwise mean 50 extra tab stops on top of that host's own roving focus. The
machine-readable dateTime attribute is there either way.
| Contract | States tested |
|---|---|
| Behaviour | default |
| Accessibility | live, status-announcement, semantic-html |
| Visual | default |