Relative Time
A semantic date and time value with relative, date, time, and datetime modes, explicit locale and timezone controls, server-first output, and explicit empty and invalid states.
Preview
<x-ui.relative-time :datetime="now()->subMinutes(5)->toIso8601String()" />
Installation
php artisan ui:add relative-time
Note
This component ships an Alpine behavior module at
resources/js/ui/relative-time.js. Import it once from your bundle so it registers on alpine:init:
import './relative-time.js';
Registry contract
php artisan ui:add relative-time
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/relative-time.blade.php -
resources/js/ui/relative-time.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Relative Time (`relative-time`)
A semantic date and time value with relative, date, time, and datetime modes, explicit locale and timezone controls, server-first output, and explicit empty and invalid states.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add relative-time
```
## Usage
```blade
<x-ui.relative-time :datetime="now()->subMinutes(5)->toIso8601String()" />
```
## Props
- `datetime` (instant|null, default `null`) — Immutable parseable instant.
- `mode` (relative|date|time|datetime, default `relative`) — Visible format.
- `length` (narrow|short|long, default `narrow`) — Relative label length, as Intl.RelativeTimeFormat style. narrow is the compact default for dense lists ("5m ago", "5 min. geleden"); long uses full words ("5 minutes ago", "5 minuten geleden"). Unknown values use narrow.
- `locale` (locale|null, default `null`) — Locale override. Without it the server uses the application locale and the browser the document lang.
- `timezone` (timezone|null, default `null`) — Display timezone override.
- `refresh` (int, default `60000`) — Declared by @props in the shipped Blade source.
- `emptyLabel` (string, default `—`) — Declared by @props in the shipped Blade source.
- `invalidLabel` (string, default `Invalid date`) — Declared by @props in the shipped Blade source.
## Use when
- Use for exact technical detail, code readability, diagnostics, and copyable reference material.
- Display one immutable instant in a relative or localized absolute form.
## Avoid when
- Do not surface developer-specific patterns in end-user flows unless the task genuinely requires them.
- Use date-picker or time-picker when a person must choose a value.
## Anti-patterns
- Exposing developer tooling in end-user flows without a task need
## Rules
- Use the `<brok:relative-time>` tag (or `<x-ui.relative-time>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/relative-time
- Registry JSON (files, props, contract): https://brokui.dev/r/open/relative-time.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="flex flex-wrap gap-6">
<x-ui.relative-time :datetime="now()" mode="date" locale="nl-NL" timezone="Europe/Amsterdam" />
<x-ui.relative-time :datetime="now()" mode="time" locale="en-GB" timezone="Europe/London" />
<x-ui.relative-time :datetime="now()" mode="datetime" locale="ar-AE" timezone="Asia/Dubai" dir="rtl" />
</div>
{{-- Relative labels follow the locale: compact by default (length narrow),
full words with length="long". The server renders the first label from
the same CLDR data the browser's Intl uses; the browser keeps it current. --}}
<dl class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
<dt class="text-muted-foreground">nl</dt>
<dd><x-ui.relative-time :datetime="now()->subMinutes(2)->toIso8601String()" locale="nl" data-testid="rt-nl-minutes" /></dd>
<dt class="text-muted-foreground">nl, long</dt>
<dd><x-ui.relative-time :datetime="now()->subMinutes(2)->toIso8601String()" locale="nl" length="long" data-testid="rt-nl-long" /></dd>
<dt class="text-muted-foreground">nl</dt>
<dd><x-ui.relative-time :datetime="now()->subDay()->toIso8601String()" locale="nl" data-testid="rt-nl-yesterday" /></dd>
<dt class="text-muted-foreground">nl</dt>
<dd><x-ui.relative-time :datetime="now()->addHours(3)->toIso8601String()" locale="nl" data-testid="rt-nl-future" /></dd>
<dt class="text-muted-foreground">en</dt>
<dd><x-ui.relative-time :datetime="now()->subMinutes(5)->toIso8601String()" locale="en" data-testid="rt-en-narrow" /></dd>
</dl>
<div class="flex flex-wrap gap-6">
<x-ui.relative-time />
<x-ui.relative-time datetime="not-a-date" />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| datetime | instant | null | null | Immutable parseable instant. |
| mode | relative | date | time | datetime | relative | Visible format. |
| length | narrow | short | long | narrow | Relative label length, as Intl.RelativeTimeFormat style. narrow is the compact default for dense lists ("5m ago", "5 min. geleden"); long uses full words ("5 minutes ago", "5 minuten geleden"). Unknown values use narrow. |
| locale | locale | null | null | Locale override. Without it the server uses the application locale and the browser the document lang. |
| timezone | timezone | null | null | Display timezone override. |
| refresh | int | 60000 | Declared by @props in the shipped Blade source. |
| emptyLabel | string | — | Declared by @props in the shipped Blade source. |
| invalidLabel | string | Invalid date | Declared by @props in the shipped Blade source. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Absolute modes are server-rendered and do not need JavaScript.
- Relative mode renders its first label on the server in the resolved locale from ICU's CLDR relative-time data, the same data Intl.RelativeTimeFormat uses (Carbon is the fallback without intl), so it reads correctly without JavaScript and does not flash on hydrate.
- In the browser the label is kept current with Intl.RelativeTimeFormat (numeric auto) and, once older than a week, an Intl.DateTimeFormat short date in the server's timezone. It rewrites the text only when the value or unit changes, on a bounded interval and when the tab becomes visible, and clears its timer and listener on destroy.
- The absolute value stays in the title and the machine-readable datetime attribute.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, behaviorTest.
Guidance
Expose exact technical or diagnostic information.
Use when
- Use for exact technical detail, code readability, diagnostics, and copyable reference material.
- Display one immutable instant in a relative or localized absolute form.
Avoid when
- Do not surface developer-specific patterns in end-user flows unless the task genuinely requires them.
- Use date-picker or time-picker when a person must choose a value.
Use instead
- Plain-language product UI for nontechnical users
Anti-patterns
- Exposing developer tooling in end-user flows without a task need
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- Renders a semantic time element with a machine-readable datetime.
- Invalid and empty values have explicit text.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="relative-time-{{ $record->id }}">
<x-ui.relative-time :datetime="now()->subMinutes(5)->toIso8601String()" />
</div>
Validation
Validation support: custom. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:relative-time
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'datetime' => null,
'mode' => 'relative',
'length' => 'narrow',
'locale' => null,
'timezone' => null,
'refresh' => 60000,
'emptyLabel' => '—',
'invalidLabel' => 'Invalid date',
])
@php
$mode = in_array($mode, ['relative', 'date', 'time', 'datetime'], true) ? $mode : 'relative';
$length = in_array($length, ['long', 'short', 'narrow'], true) ? $length : 'narrow';
$valid = filled($datetime);
$instant = null;
if ($valid) {
try {
$instant = \Carbon\CarbonImmutable::parse($datetime);
if (filled($timezone)) {
$instant = $instant->setTimezone((string) $timezone);
}
} catch (\Throwable) {
$valid = false;
}
}
$isoTarget = $instant?->toIso8601String();
$appLocale = (string) app()->getLocale();
$resolvedLocale = str_replace('-', '_', (string) ($locale ?: $appLocale));
$initialLabel = filled($datetime) ? __($invalidLabel) : __($emptyLabel);
$absolute = null;
$bucket = null;
$timezoneName = null;
/*
* The relative label from ICU's CLDR fields, the data Intl.RelativeTimeFormat
* (numeric: auto) reads in the browser, so both sides print the same text.
* Resolves the narrow -> short -> long fallback and the locale -> language
* chain that PHP's ResourceBundle does not follow. Null without intl data.
*/
$icuRelative = static function (string $locale, int $value, string $unit, string $length): ?string {
if (! class_exists(\ResourceBundle::class) || ! class_exists(\MessageFormatter::class) || ! class_exists(\NumberFormatter::class)) {
return null;
}
$locale = \Locale::canonicalize($locale) ?: $locale;
$chain = array_values(array_unique(array_filter([$locale, \Locale::getPrimaryLanguage($locale)])));
$suffixes = match ($length) {
'narrow' => ['-narrow', '-short', ''],
'short' => ['-short', ''],
default => [''],
};
$lookup = static function (string $part) use ($chain, $unit, $suffixes): ?\ResourceBundle {
foreach ($suffixes as $suffix) {
foreach ($chain as $candidate) {
$bundle = \ResourceBundle::create($candidate, 'ICUDATA', true);
$fields = $bundle instanceof \ResourceBundle ? $bundle['fields'] : null;
$field = $fields instanceof \ResourceBundle ? $fields[$unit.$suffix] : null;
$data = $field instanceof \ResourceBundle ? $field[$part] : null;
if ($data instanceof \ResourceBundle) {
return $data;
}
}
}
return null;
};
try {
$named = $lookup('relative')?->get((string) $value);
if (is_string($named)) {
return $named;
}
$patterns = $lookup('relativeTime')?->get($value < 0 ? 'past' : 'future');
if (! $patterns instanceof \ResourceBundle) {
return null;
}
$count = abs($value);
$category = (new \MessageFormatter($locale, '{0,plural,zero{zero}one{one}two{two}few{few}many{many}other{other}}'))->format([$count]);
$pattern = $patterns->get((string) $category) ?? $patterns->get('other');
return is_string($pattern)
? str_replace('{0}', (string) (new \NumberFormatter($locale, \NumberFormatter::DECIMAL))->format($count), $pattern)
: null;
} catch (\Throwable) {
return null;
}
};
if ($instant) {
$timezoneName = $instant->getTimezone()->getName();
if ($timezoneName === 'Z') {
$timezoneName = 'UTC';
} elseif (preg_match('/^[+-]\d{2}:\d{2}$/', $timezoneName) === 1) {
$timezoneName = 'GMT'.$timezoneName;
}
$formatter = null;
if (class_exists(\IntlDateFormatter::class)) {
$dateStyle = $mode === 'time' ? \IntlDateFormatter::NONE : \IntlDateFormatter::MEDIUM;
$timeStyle = $mode === 'date' ? \IntlDateFormatter::NONE : \IntlDateFormatter::SHORT;
$formatter = new \IntlDateFormatter($resolvedLocale, $dateStyle, $timeStyle, $timezoneName);
}
$absolute = $formatter?->format($instant->getTimestamp()) ?: $instant->format(match ($mode) {
'date' => 'Y-m-d',
'time' => 'H:i',
default => 'Y-m-d H:i',
});
if ($mode !== 'relative') {
$initialLabel = $absolute;
} else {
$now = \Carbon\CarbonImmutable::now($instant->getTimezone());
$seconds = (int) round($instant->getTimestamp() - $now->getTimestamp());
$abs = abs($seconds);
$direction = $seconds > 0 ? 1 : -1;
// The same threshold ladder as relative-time.js, so the client keeps this text until the bucket changes.
[$value, $unit] = match (true) {
$abs < 45 => [0, 'second'],
$abs < 3600 => [(int) round($abs / 60), 'minute'],
$abs < 86400 => [(int) round($abs / 3600), 'hour'],
$abs < 86400 * 7 => [(int) round($abs / 86400), 'day'],
default => [null, 'date'],
};
if ($unit === 'date') {
$sameYear = $instant->year === $now->year;
$pattern = class_exists(\IntlDatePatternGenerator::class)
? (new \IntlDatePatternGenerator($resolvedLocale))->getBestPattern($sameYear ? 'dMMM' : 'dMMMy')
: null;
$dateFormatter = $pattern && class_exists(\IntlDateFormatter::class)
? new \IntlDateFormatter($resolvedLocale, \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, $timezoneName, null, $pattern)
: null;
$initialLabel = $dateFormatter?->format($instant->getTimestamp())
?: $instant->locale($resolvedLocale)->isoFormat($sameYear ? 'D MMM' : 'D MMM YYYY');
$bucket = 'date';
} else {
$signed = $value === 0 ? 0 : $direction * $value;
$initialLabel = $icuRelative($resolvedLocale, $signed, $unit, $length);
if ($initialLabel === null) {
$reference = $value === 0 ? $now : $now->add($signed, $unit);
$initialLabel = $reference->locale($resolvedLocale)->diffForHumans($now, [
'syntax' => \Carbon\CarbonInterface::DIFF_RELATIVE_TO_NOW,
'options' => \Carbon\CarbonInterface::JUST_NOW | \Carbon\CarbonInterface::ONE_DAY_WORDS,
'short' => $length !== 'long',
]);
}
$bucket = $signed.':'.$unit;
}
}
}
$config = $instant && $mode === 'relative'
? [
'datetime' => $isoTarget,
'refresh' => max(1000, (int) $refresh),
'length' => $length,
'locale' => filled($locale) ? str_replace('_', '-', (string) $locale) : null,
'serverLocale' => str_replace('_', '-', $resolvedLocale),
'timeZone' => $timezoneName,
'bucket' => $bucket,
]
: null;
@endphp
<time
data-slot="relative-time"
data-mode="{{ $mode }}"
@if ($isoTarget) datetime="{{ $isoTarget }}" title="{{ $absolute }}" @endif
@if ($config) x-data="uiRelativeTime(@js($config))" @endif
@if (filled($datetime) && ! $valid) aria-invalid="true" @endif
{{ $attributes->merge(['class' => 'tabular-nums text-muted-foreground']) }}
>{{ $initialLabel }}</time>
/**
* Relative time behavior.
*
* Keeps a server-rendered relative label current while the page is open. The
* Blade renders the first label in the resolved locale from ICU's CLDR data
* (Carbon without intl), so the value is correct without JavaScript and does
* not flash on hydrate. After that the label is formatted with
* `Intl.RelativeTimeFormat`, narrow by default ("5m ago", "2 min. geleden",
* "gisteren"; long: "2 minuten geleden"), and, once older than a week, with
* `Intl.DateTimeFormat` as a short date.
*
* The client keeps the server text while the value stays in the same bucket
* (value and unit) and the locale is the same; it rewrites the text only when the
* bucket or locale changes. The label refreshes every `refresh` ms and when the
* tab becomes visible again; `destroy()` clears the timer and the listener.
*
* Locale order: the `locale` prop, then the document `lang`, then the server
* application locale.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiRelativeTime', (config = {}) => ({
ts: config.datetime ? Date.parse(config.datetime) : NaN,
refresh: Math.max(1000, config.refresh ?? 60000),
locale: undefined,
bucket: null,
timer: null,
onVisible: null,
init() {
if (Number.isNaN(this.ts)) {
return;
}
this.locale = this.resolveLocale();
this.bucket = this.locale === config.serverLocale ? (config.bucket ?? null) : null;
this.update();
this.timer = setInterval(() => this.update(), this.refresh);
this.onVisible = () => {
if (document.visibilityState === 'visible') {
this.update();
}
};
document.addEventListener('visibilitychange', this.onVisible);
},
destroy() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.onVisible) {
document.removeEventListener('visibilitychange', this.onVisible);
this.onVisible = null;
}
},
resolveLocale() {
const candidates = [config.locale, document.documentElement.lang, config.serverLocale];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
try {
return Intl.getCanonicalLocales(String(candidate).replace(/_/g, '-'))[0];
} catch (e) {
// Try the next candidate.
}
}
return undefined;
},
update() {
if (Number.isNaN(this.ts)) {
return;
}
const next = this.bucketFor(this.ts, Date.now());
if (next.key === this.bucket) {
return;
}
this.bucket = next.key;
const label = this.format(next);
if (label && this.$el.textContent !== label) {
this.$el.textContent = label;
}
},
/** The same threshold ladder as the Blade, so both sides agree on the bucket. */
bucketFor(ts, now) {
const seconds = Math.round((ts - now) / 1000);
const abs = Math.abs(seconds);
const direction = seconds > 0 ? 1 : -1;
let value;
let unit;
if (abs < 45) {
return { key: '0:second', value: 0, unit: 'second' };
} else if (abs < 3600) {
[value, unit] = [Math.round(abs / 60), 'minute'];
} else if (abs < 86400) {
[value, unit] = [Math.round(abs / 3600), 'hour'];
} else if (abs < 86400 * 7) {
[value, unit] = [Math.round(abs / 86400), 'day'];
} else {
const sameYear = new Date(ts).getFullYear() === new Date(now).getFullYear();
return { key: 'date', unit: 'date', sameYear };
}
return { key: `${direction * value}:${unit}`, value: direction * value, unit };
},
format(bucket) {
try {
if (bucket.unit === 'date') {
const options = { day: 'numeric', month: 'short' };
if (!bucket.sameYear) {
options.year = 'numeric';
}
if (config.timeZone) {
options.timeZone = config.timeZone;
}
try {
return new Intl.DateTimeFormat(this.locale, options).format(this.ts);
} catch (e) {
delete options.timeZone;
return new Intl.DateTimeFormat(this.locale, options).format(this.ts);
}
}
const length = ['long', 'short', 'narrow'].includes(config.length) ? config.length : 'narrow';
return new Intl.RelativeTimeFormat(this.locale, { numeric: 'auto', style: length }).format(
bucket.value,
bucket.unit,
);
} catch (e) {
return '';
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0