Relative Time
relative-timeuiLive relative timestamp — "2 minutes ago", "yesterday", "in 3 days" — via Intl.RelativeTimeFormat. `display` switches the label to relative, absolute, or both. `timeZone` (IANA or `UTC`) formats the clock and tooltip; omit it for the browser local zone. `parseAs` treats naive ISO strings as local or UTC. Renders a semantic <time>. Pass `now` to freeze the clock for tests and SSR.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/relative-time.json$npx shadcn@latest add https://uipkge.dev/r/lit/relative-time.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/relative-time.json$bunx shadcn@latest add https://uipkge.dev/r/lit/relative-time.jsonManifest:
https://uipkge.dev/r/lit/relative-time.jsonInstalls to:src/uip/components/relative-time/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
format-style | RelativeTimeStyle | long | optional |
numeric | RelativeTimeNumeric | auto | optional |
locale | string | — | optional |
display | RelativeTimeDisplay | relative | optional |
time-zone | string | — | optional |
parse-as | RelativeTimeParseAs | local | optional |
update-interval | number | 30_000 | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-relative-time': UipRelativeTime
}
}Used by
Files installed (2)
src/uip/components/relative-time/format-relative-time.ts2.3 kB
export type RelativeTimeStyle = 'long' | 'short' | 'narrow' export type RelativeTimeNumeric = 'always' | 'auto' export type RelativeTimeParseAs = 'local' | 'utc' export type RelativeTimeDisplay = 'relative' | 'absolute' | 'both' const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ { amount: 60, unit: 'second' }, { amount: 60, unit: 'minute' }, { amount: 24, unit: 'hour' }, { amount: 7, unit: 'day' }, { amount: 4.34524, unit: 'week' }, { amount: 12, unit: 'month' }, { amount: Number.POSITIVE_INFINITY, unit: 'year' }, ] const NAIVE_ISO = /^(\d{4}-\d{2}-\d{2})(?:T(\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?))?$/ export function toDate(value: Date | string | number, parseAs: RelativeTimeParseAs = 'local'): Date { if (value instanceof Date) return value if (typeof value === 'number') return new Date(value) if (parseAs === 'utc') { const naive = value.match(NAIVE_ISO) if (naive) return new Date(`${naive[1]}T${naive[2] ?? '00:00:00.000'}Z`) } return new Date(value) } export function formatRelativeTime( date: Date, now: Date, options?: { style?: RelativeTimeStyle; numeric?: RelativeTimeNumeric; locale?: string }, ): string { const rtf = new Intl.RelativeTimeFormat(options?.locale, { numeric: options?.numeric ?? 'auto', style: options?.style ?? 'long', }) let duration = (date.getTime() - now.getTime()) / 1000 for (const division of DIVISIONS) { if (Math.abs(duration) < division.amount) { return rtf.format(Math.round(duration), division.unit) } duration /= division.amount } return rtf.format(0, 'second') } export function formatAbsoluteTime(date: Date, locale?: string, timeZone?: string): string { return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone, }).format(date) } export function formatVisibleTime( date: Date, now: Date, options?: { display?: RelativeTimeDisplay style?: RelativeTimeStyle numeric?: RelativeTimeNumeric locale?: string timeZone?: string }, ): string { const relative = formatRelativeTime(date, now, options) if ((options?.display ?? 'relative') === 'relative') return relative const absolute = formatAbsoluteTime(date, options?.locale, options?.timeZone) if (options?.display === 'absolute') return absolute return `${relative} · ${absolute}` }src/uip/components/relative-time/relative-time.ts4.1 kB
import { LitElement, css, html, isServer, nothing } from 'lit' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { formatAbsoluteTime, formatVisibleTime, toDate, type RelativeTimeDisplay, type RelativeTimeNumeric, type RelativeTimeParseAs, type RelativeTimeStyle, } from './format-relative-time' type DateInput = Date | string | number // Attributes are strings; an all-digit string is epoch ms (React accepts numbers). const fromAttr = (v: string | null) => (v == null ? undefined : /^-?\d+$/.test(v) ? Number(v) : v) /** * <uip-relative-time> — the registry RelativeTime: a <time> showing "5 minutes * ago" (or absolute / both), with the full localized datetime as its title. * * Ticks every `update-interval` ms (default 30 000; `0` freezes) unless `now` * is given. The timer only runs in the browser while connected, so SSR output * is a single stable render. Slotted content replaces the label (React's * `children`). * * React's `className` → style the part from outside, e.g. * `class="[&::part(base)]:text-xs"`. */ export class UipRelativeTime extends LitElement { // display: contents so the inner <time> is laid out where React's is (inline // in text, a flex item in a flex row). An inline host that gets blockified // as a flex item adds its own line box (inherited line-height) around the // smaller <time> text and grows the row. static styles = [tailwind, css`:host { display: contents; }`] static properties = { date: { converter: { fromAttribute: fromAttr } }, now: { converter: { fromAttribute: fromAttr } }, formatStyle: { attribute: 'format-style' }, numeric: {}, locale: {}, display: {}, timeZone: { attribute: 'time-zone' }, parseAs: { attribute: 'parse-as' }, updateInterval: { type: Number, attribute: 'update-interval' }, } date?: DateInput now?: DateInput formatStyle: RelativeTimeStyle = 'long' numeric: RelativeTimeNumeric = 'auto' locale?: string display: RelativeTimeDisplay = 'relative' timeZone?: string parseAs: RelativeTimeParseAs = 'local' updateInterval = 30_000 private timer?: ReturnType<typeof setInterval> constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'relative-time') this.syncTimer() } disconnectedCallback() { super.disconnectedCallback() this.stopTimer() } protected updated(changed: Map<string, unknown>) { if (changed.has('updateInterval') || changed.has('now')) this.syncTimer() } private stopTimer() { clearInterval(this.timer) this.timer = undefined } private syncTimer() { this.stopTimer() if (isServer || !this.isConnected || this.updateInterval <= 0 || this.now !== undefined) return this.timer = setInterval(() => this.requestUpdate(), this.updateInterval) } render() { if (this.date === undefined || this.date === '') return nothing const resolvedDate = toDate(this.date, this.parseAs) if (Number.isNaN(resolvedDate.getTime())) return nothing const resolvedNow = this.now === undefined ? new Date() : toDate(this.now, this.parseAs) const label = formatVisibleTime(resolvedDate, resolvedNow, { display: this.display, style: this.formatStyle, numeric: this.numeric, locale: this.locale, timeZone: this.timeZone, }) const absolute = formatAbsoluteTime(resolvedDate, this.locale, this.timeZone) return html`<time part="base" data-uipkge="" data-slot="relative-time" data-display=${this.display} data-timezone=${this.timeZone || 'local'} data-parse-as=${this.parseAs} datetime=${resolvedDate.toISOString()} title=${absolute} class="text-muted-foreground text-sm tabular-nums" ><slot>${label}</slot></time >` } } customElements.get('uip-relative-time') || customElements.define('uip-relative-time', UipRelativeTime) declare global { interface HTMLElementTagNameMap { 'uip-relative-time': UipRelativeTime } }
Raw manifest:https://uipkge.dev/r/lit/relative-time.json