Skip to content
Brok UI

Loading…

No results

Relative Time

Open source

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.

Version
v1.2.1
Stability
stable
License
MIT
Related
Date Picker
Time Picker

Preview

Mode
previews.components.relative-time.default.blade.php Blade
<x-ui.relative-time :datetime="now()->subMinutes(5)->toIso8601String()" />
Relative Current
Date Current
Time Current
Datetime Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/relative-time.blade.php
  • js resources/js/ui/relative-time.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

relative-time.md
# 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

absolute.blade.php Blade
<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>
locales.blade.php Blade
{{-- 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>
states.blade.php Blade
<div class="flex flex-wrap gap-6">
    <x-ui.relative-time />
    <x-ui.relative-time datetime="not-a-date" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

relative-time

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

Developer and documentation tool

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
semantic time value
Theming hooks
Uses semantic muted text and tabular numbers.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/relative-time.blade.php Blade
@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>
resources/js/ui/relative-time.js JS
/**
 * 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