Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.1.0
Accessibility pattern
native time element

Last updated

Last deployed

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-time

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/relative-time

Run 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 client

The 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 s m h d w mo y, no space before the unit.
  • Calendar days use the viewer's time zone (timeZone option 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

SurfaceUseReads
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 rowsformatDateTime / <DateTime variant="datetime">Sep 25 · 2:30 PM
Hover tooltipsformatTooltip (built into every component)Sep 25, 2026 · 2:30 PM IST
Notifications, feedsformatRelative + groupByDay headers2m under "Today"
SentencesformatRelative({ style: "suffix" }), separator: "comma"2m ago · Sep 25, 2:30 PM
Due chipsformatDueLabel / <DueLabel>Overdue 2d · Due today · Due Sep 30
Durations (meetings, audio, SLAs)formatDuration / <Duration>; clock for players1h 15m · 1:15:04
Durations in sentences and emailsformatDuration({ style: "long" })1 hour 15 minutes · 7 days
Page headers, greetingsformatLongDate; hourOfDay for the greetingThursday, September 24
Schedules, eventsformatDateRange, formatTimeOfDaySep 25 · 2–3:30 PM
Grouped lists (inbox, activity)groupByDayToday / Yesterday / This week / Last week / Earlier
Stored calendar days (a date field)formatCalendarDate; parseCalendarDay / toCalendarDayOct 8, 2026 — the same day in every zone
API / JSONISO strings — never formatted text2026-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):

DateTime — data-slot="date-time"
DueLabel — data-slot="due-label"
Duration — data-slot="duration"
RelativeTime — data-slot="relative-time"
TimeZoneProvider
TimeZoneScript

Examples

formatRelative

now · 2m · 3h · 5d · 1w · 2mo · 1y · in 2d. style: "suffix" gives "2m ago"; style: "long" gives "2 minutes ago".

30s agonow
2 min ago2m
3 h ago3h
5 days ago5d
9 days ago1w
70 days ago2mo
400 days ago1y
in 2 daysin 2d
suffix2m ago
long2 minutes ago

formatDuration

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".

4242s
1502m
45001h 15m
36001h
1836002d 3h
4504, clock1:15:04
4500, long1 hour 15 minutes

formatDate 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 4
last yearDec 6, 2025
+20 days, looseFuturein 3 weeks
formatTimeOfDay5:30 PM
formatLongDateThursday, January 15
formatLongDate, en-INThursday, 15 January

formatDateTime 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 PM
last yearDec 6, 2025 · 5:30 PM
commaJan 5, 5:30 PM
range, same dayJan 15 · 5:30–7 PM
range, same monthJan 17–20
range, two monthsJan 25 – Feb 4
range, two yearsDec 26, 2025 – Jan 5, 2026

formatDueLabel

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 viewer

groupByDay

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.

Today
Today · 5:30 PM
Yesterday
Yesterday · 5:30 PM
This week
Jan 13 · 5:30 PM
Last week
Jan 6 · 5:30 PM
Earlier
Dec 6, 2025 · 5:30 PM

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.

suffix
minimal
long

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}.

Instant (delay 0)
Delayed 700ms

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.

en-US
de-DE
fr-FR

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.

Due

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.

UTCAsia/Kolkata

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.

Updated

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

PropTypeDefaultDescription
date*string | number | Date—The instant to render, relative to now. Accepts a Date, an ISO string, or an epoch-millisecond number.
capitalizebooleanfalseUpper-case the label's first letter — "Today", "Yesterday", "Now" — for a label that stands on its own rather than inside a sentence.
focusableboolean—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".
formatOptionsIntl.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.
localestring | 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.
nownumberDate.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).
refreshbooleantrueAuto-refresh from the shared ten-second clock. No per-row timer is created. Ignored when now is provided.
timeZonestring—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.
titlestring | booleantrueReveal 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.
tooltipDelaynumberTIMINGS.tooltipOpenDelayMsHow 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.
withTimebooleanfalseIn 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

AttributeValues
data-modemirrors 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-visible shows a 2px ring (outline-ring) — never outline: none alone.
  • Color is inherited from context — the component never encodes meaning in color, so the relative string stands on its own.
KeyAction
TabMove focus to the timestamp (when the tooltip is enabled).
EscDismiss 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.

ContractStates tested
Behaviourdefault
Accessibilitylive, status-announcement, semantic-html
Visualdefault

Do / Don't

Do
Pick the function from the where-to-use table — formatRelative for activity, formatDate for dates, formatDateTime for detail rows, formatDueLabel for due chips, formatDuration for lengths.
Don't
Call toLocaleString, hand-roll 'minutes ago', or add a date library — and never put formatted text in API/JSON output.

On this page