Skip to content
LarawellUi

<x-widget.clock>

Clock

Live clocks: digital and analog, world clocks across timezones, countdowns to a date, and a stopwatch or timer. First paint from the server, then they tick in the browser. Works inside Livewire components: they keep ticking, and a running stopwatch keeps running, through renders.

php artisan larawell:add clock
Also adds
Button, Icon

Usage

Livewire

Inside a Livewire component the clocks keep ticking through every render, and world clocks keep their offsets from the visitor. A countdown follows its :to, so moving the deadline in PHP moves it. A stopwatch or timer is the browser's alone and keeps running through renders; its props are read once, when it first appears, so give it a wire:key that changes to start a new one. Listen for clock:finished to call the component when a countdown or timer reaches zero.

Blade
<div x-on:clock:finished="$wire.timeUp()" class="space-y-6">
    <x-widget.clock :timezone="$user->timezone" seconds date />

    <x-widget.clock.countdown :to="$auction->ends_at" label="Bidding closes in" />

    <x-widget.clock.stopwatch mode="timer" :duration="$round->seconds" wire:key="round-{{ $round->id }}" />
</div>

Examples

Digital

timezone is required, so the time the server paints is the time the browser ticks on. For a user's own time, store their timezone and pass it, e.g. :timezone="$user->timezone". hour12, seconds and date add the rest.

Mumbai
New York Tuesday, 6 October
London office
Show code
Blade
<div class="flex flex-wrap items-end gap-10">
    <x-widget.clock timezone="Asia/Kolkata" label="Mumbai" />
    <x-widget.clock timezone="America/New_York" label="New York" hour12 seconds date />
    <x-widget.clock timezone="Europe/London" label="London office" size="sm" />
</div>

Analog

A face with hands. numbers adds 12, 3, 6 and 9; seconds can be turned off for a calmer face. Screen readers get the time in words.

London
Tokyo
New York
Show code
Blade
<div class="flex flex-wrap items-end gap-10">
    <x-widget.clock.analog timezone="Europe/London" label="London" />
    <x-widget.clock.analog timezone="Asia/Tokyo" label="Tokyo" numbers />
    <x-widget.clock.analog timezone="America/New_York" label="New York" :seconds="false" size="sm" />
</div>

World

Several cities at once, each with how far ahead or behind the visitor it is, "Tomorrow" when the date has rolled over, and day or night.

  • London +1h
  • New York −4h
  • Mumbai +5h 30m
  • Tokyo +9h
Show code
Blade
<x-widget.clock.world class="w-full" :zones="[
    'London' => 'Europe/London',
    'New York' => 'America/New_York',
    'Mumbai' => 'Asia/Kolkata',
    'Tokyo' => 'Asia/Tokyo',
]" />

Countdown

Counts down to a date: launches, sale ends. The server's clock is the reference, so a wrong device clock doesn't matter. At zero it shows the done message, announces it, and fires clock:finished.

Launch in
2 days, 4 hours left

Show code
Blade
<x-widget.clock.countdown label="Launch in" :to="now()->addDays(2)->addHours(4)" done="We're live!" />

Countdown inline

variant="inline" sits in running text. Short ones suit one-time codes and sessions; this one ends in 15 seconds.

Sale ends in 5 hours, 12 minutes left

Your code expires in 15 seconds left

Show code
Blade
<div class="flex flex-col gap-3 text-sm">
    <p>Sale ends in <x-widget.clock.countdown variant="inline" :to="now()->addHours(5)->addMinutes(12)" label="Sale ends in" /></p>
    <p>Your code expires in <x-widget.clock.countdown variant="inline" :to="now()->addSeconds(15)" label="Code expires in" done="Code expired. Request a new one." /></p>
</div>

Stopwatch

mode="stopwatch" counts up; mode="timer" counts down from duration (in seconds), announces the end and fires clock:finished. Pause keeps the time; Reset starts over.

Lap time
0:00.0

Plank
1:30

Show code
Blade
<div class="flex flex-wrap items-start gap-12">
    <x-widget.clock.stopwatch label="Lap time" />
    <x-widget.clock.stopwatch mode="timer" :duration="90" label="Plank" done="Done. Take a break." />
</div>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.clock>

Prop Default Description
timezone Required Required: an IANA timezone like "Europe/London".
hour12 false 12-hour time with AM or PM, instead of 24-hour.
seconds false Shows the seconds as well.
date false A line under the time: "Friday, 27 September".
label null Small text above the time, e.g. the city.
size 'md' How big the time is: sm, md or lg.

<x-widget.clock.analog>

Prop Default Description
timezone Required Required: an IANA timezone like "Europe/London".
seconds true Draws a seconds hand.
numbers false Puts 12, 3, 6 and 9 on the face.
label null A caption under the face, e.g. the city.
size 'md' How big the face is: sm, md or lg.

<x-widget.clock.countdown>

Prop Default Description
to Required When it ends: a date string ("2026-12-31 23:59") in the app's timezone, or a Carbon/DateTime instance.
label 'Time left' Small text above the boxes (not shown inline), and the countdown's name for screen readers.
done "Time's up" Shown in place of the numbers, and announced, once it reaches zero.
variant 'boxes' boxes: a card per unit. inline: "2d 04h 12m 09s" in running text.
size 'md' How big the numbers are: sm, md or lg.

<x-widget.clock.stopwatch>

Prop Default Description
mode 'stopwatch' stopwatch counts up from zero; timer counts down from duration.
duration 60 Timer length in seconds, e.g. 1500 for 25 minutes.
label null Small text above the time, and its name for screen readers.
done "Time's up" Announced, and shown, when a timer reaches zero.
size 'md' How big the time is: sm, md or lg.

<x-widget.clock.world>

Prop Default Description
zones [] ['London' => 'Europe/London', 'Tokyo' => 'Asia/Tokyo'], or a list of ['label' => …, 'timezone' => …].
hour12 false 12-hour time with AM or PM, instead of 24-hour.
label 'World clocks' The list's name for screen readers.

Accessibility

All 6 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What larawell:add clock writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/clock/analog.blade.php Show
analog.blade.php
@props([
    // Required: an IANA timezone like "Europe/London".
    'timezone',
    // Draws a seconds hand.
    'seconds' => true,
    // Puts 12, 3, 6 and 9 on the face.
    'numbers' => false,
    // A caption under the face, e.g. the city.
    'label' => null,
    // How big the face is: sm, md or lg.
    'size' => 'md',
])

@php
    // Required, so the server paints the same time the browser will; pass the user's own for "their" time.
    if (! isset($timezone) || ! is_string($timezone) || $timezone === '') {
        throw new \InvalidArgumentException('<x-widget.clock.analog> needs a timezone, e.g. timezone="Europe/London" or :timezone="$user->timezone".');
    }
    $now = now($timezone);
    $sizes = ['sm' => 'size-24', 'md' => 'size-36', 'lg' => 'size-52'];
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! array_key_exists($size, $sizes)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.clock.analog>. Use one of: ".implode(', ', array_keys($sizes)).'.');
    }
    // Hand angles, clockwise from 12. resources/js/widget/clock sets the same rotation every second.
    $angles = [
        'hour' => ($now->hour % 12) * 30 + $now->minute * 0.5,
        'minute' => $now->minute * 6 + $now->second * 0.1,
        'second' => $now->second * 6,
    ];
    // The SVG transform attribute, not style="": a Content Security Policy doesn't restrict it, and rotate(a 50 50)
    // turns the hand about the centre by itself.
    $hand = fn (string $name): string => "rotate({$angles[$name]} 50 50)";
@endphp

<div
    data-clock="analog"
    data-timezone="{{ $timezone }}"
    {{ $attributes->class(['inline-flex flex-col items-center gap-2']) }}
>
    <svg viewBox="0 0 100 100" aria-hidden="true" class="{{ $sizes[$size] }}">
        <circle cx="50" cy="50" r="48" class="fill-surface stroke-line" stroke-width="2" />
        @for ($i = 0; $i < 12; $i++)
            {{-- Longer, darker marks at the quarters. --}}
            <line
                x1="50" y1="{{ $i % 3 === 0 ? 6 : 7 }}" x2="50" y2="{{ $i % 3 === 0 ? 13 : 10 }}"
                stroke-linecap="round" stroke-width="{{ $i % 3 === 0 ? 2.5 : 1.5 }}"
                class="{{ $i % 3 === 0 ? 'stroke-foreground' : 'stroke-muted' }}"
                transform="rotate({{ $i * 30 }} 50 50)"
            />
        @endfor
        @if ($numbers)
            @foreach ([12 => [50, 23], 3 => [79, 53.5], 6 => [50, 84], 9 => [21, 53.5]] as $n => [$x, $y])
                <text x="{{ $x }}" y="{{ $y }}" text-anchor="middle" class="fill-foreground text-[9px] font-semibold">{{ $n }}</text>
            @endforeach
        @endif

        <line data-clock-hand="hour" x1="50" y1="50" x2="50" y2="28" stroke-linecap="round" stroke-width="4" class="stroke-foreground" transform="{{ $hand('hour') }}" />
        <line data-clock-hand="minute" x1="50" y1="50" x2="50" y2="15" stroke-linecap="round" stroke-width="3" class="stroke-foreground" transform="{{ $hand('minute') }}" />
        @if ($seconds)
            <line data-clock-hand="second" x1="50" y1="58" x2="50" y2="12" stroke-linecap="round" stroke-width="1.5" class="stroke-error" transform="{{ $hand('second') }}" />
        @endif
        <circle cx="50" cy="50" r="3" class="{{ $seconds ? 'fill-error' : 'fill-foreground' }}" />
    </svg>

    @if ($label)
        <span class="text-foreground text-sm font-medium">{{ $label }}</span>
    @endif
    {{-- The face is a picture; this is the time in words for screen readers, updated each minute. --}}
    <time data-clock-spoken datetime="{{ $now->toIso8601String() }}" class="sr-only">{{ $now->format('g:i A') }}</time>
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/countdown.blade.php Show
countdown.blade.php
@props([
    // When it ends: a date string ("2026-12-31 23:59") in the app's timezone, or a Carbon/DateTime instance.
    'to',
    // Small text above the boxes (not shown inline), and the countdown's name for screen readers.
    'label' => 'Time left',
    // Shown in place of the numbers, and announced, once it reaches zero.
    'done' => "Time's up",
    // boxes: a card per unit. inline: "2d 04h 12m 09s" in running text.
    'variant' => 'boxes',
    // How big the numbers are: sm, md or lg.
    'size' => 'md',
])

@php
    $end = \Illuminate\Support\Carbon::parse($to, config('app.timezone'));
    $now = now();
    // Rounded up, as the script does, so the first tick in the browser doesn't jump a second up.
    $left = max(0, (int) ceil($now->diffInSeconds($end, false)));
    // Days only when there are any to begin with, so the layout doesn't shift as it runs.
    $withDays = $left >= 86400;
    $units = [
        'days' => intdiv($left, 86400),
        'hours' => intdiv($left % 86400, 3600),
        'minutes' => intdiv($left % 3600, 60),
        'seconds' => $left % 60,
    ];
    if (! $withDays) {
        unset($units['days']);
    }
    $names = ['days' => ['Days', 'd'], 'hours' => ['Hours', 'h'], 'minutes' => ['Minutes', 'm'], 'seconds' => ['Seconds', 's']];
    $pad = fn (string $unit, int $n): string => $unit === 'days' ? (string) $n : str_pad((string) $n, 2, '0', STR_PAD_LEFT);
    // Spoken without the zero units ("13 seconds left", not "0 hours, 0 minutes, 13 seconds left").
    $spoken = collect($units)->filter()->whenEmpty(fn ($c) => $c->put('seconds', 0))->map(fn (int $n, string $unit): string => $n.' '.($n === 1 ? rtrim($unit, 's') : $unit))->implode(', ');
    // Phones share the width between the boxes, so md and lg step down a size there.
    $sizes = ['sm' => 'text-xl', 'md' => 'text-2xl sm:text-3xl', 'lg' => 'text-4xl sm:text-5xl'];
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($variant, ['boxes', 'inline'], true)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.clock.countdown>. Use one of: boxes, inline.");
    }
    if (! array_key_exists($size, $sizes)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.clock.countdown>. Use one of: ".implode(', ', array_keys($sizes)).'.');
    }
    $inline = $variant === 'inline';
    $numbers = $sizes[$size];
@endphp

{{-- data-now is the server's clock, so a visitor whose device clock is off still sees the right time left. --}}
{{-- Inline, everything is a span: it sits inside running text, and a div would end the paragraph. --}}
<{{ $inline ? 'span' : 'div' }}
    data-clock="countdown"
    data-to="{{ $end->getTimestampMs() }}"
    data-now="{{ $now->getTimestampMs() }}"
    data-done="{{ $done }}"
    @if ($left === 0) data-finished @endif
    {{ $attributes->class(['group/countdown', 'inline-flex flex-col gap-2 max-sm:flex max-sm:w-full' => $variant !== 'inline', 'inline' => $variant === 'inline']) }}
>
    @if ($variant !== 'inline' && $label)
        <span class="text-foreground/60 text-xs">{{ $label }}</span>
    @endif

    {{-- role="timer" without aria-live: not announced every second. The words below read when reached. --}}
    <{{ $inline ? 'span' : 'div' }} role="timer" @if ($label) aria-label="{{ $label }}" @endif class="group-data-finished/countdown:hidden">
        <span data-clock-spoken class="sr-only">{{ $spoken }} left</span>
        @if ($variant === 'inline')
            <span aria-hidden="true" class="text-foreground font-semibold tabular-nums">
                @foreach ($units as $unit => $n)
                    <span data-clock-unit="{{ $unit }}">{{ $pad($unit, $n) }}</span>{{ $names[$unit][1] }}
                @endforeach
            </span>
        @else
            <div aria-hidden="true" class="flex gap-2 max-sm:gap-1.5">
                @foreach ($units as $unit => $n)
                    <div class="bg-field flex min-w-16 flex-col items-center rounded-xl px-3 py-2 max-sm:min-w-0 max-sm:flex-1 max-sm:px-1">
                        <span data-clock-unit="{{ $unit }}" class="{{ $numbers }} text-foreground leading-tight font-semibold tabular-nums">{{ $pad($unit, $n) }}</span>
                        <span class="text-foreground/60 text-xs">{{ $names[$unit][0] }}</span>
                    </div>
                @endforeach
            </div>
        @endif
    </{{ $inline ? 'span' : 'div' }}>

    {{-- Always present and empty until the end: a status region only announces text added while it is on the page. --}}
    <{{ $inline ? 'span' : 'p' }} role="status" data-clock-done class="text-foreground font-medium">{{ $left === 0 ? $done : '' }}</{{ $inline ? 'span' : 'p' }}>
</{{ $inline ? 'span' : 'div' }}><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/index.blade.php Show
index.blade.php
@props([
    // Required: an IANA timezone like "Europe/London".
    'timezone',
    // 12-hour time with AM or PM, instead of 24-hour.
    'hour12' => false,
    // Shows the seconds as well.
    'seconds' => false,
    // A line under the time: "Friday, 27 September".
    'date' => false,
    // Small text above the time, e.g. the city.
    'label' => null,
    // How big the time is: sm, md or lg.
    'size' => 'md',
])

@php
    // The first paint comes from the server so there's no empty flash; resources/js/widget/clock takes over.
    // Required, so the server paints the same time the browser will; pass the user's own for "their" time.
    if (! isset($timezone) || ! is_string($timezone) || $timezone === '') {
        throw new \InvalidArgumentException('<x-widget.clock> needs a timezone, e.g. timezone="Europe/London" or :timezone="$user->timezone".');
    }
    $now = now($timezone);
    $sizes = ['sm' => 'text-lg', 'md' => 'text-3xl', 'lg' => 'text-5xl'];
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! array_key_exists($size, $sizes)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.clock>. Use one of: ".implode(', ', array_keys($sizes)).'.');
    }
@endphp

<div
    data-clock="digital"
    data-timezone="{{ $timezone }}"
    @if ($hour12) data-hour12 @endif
    @if ($seconds) data-seconds @endif
    {{ $attributes->class(['inline-flex flex-col gap-1']) }}
>
    @if ($label)
        <span class="text-foreground/60 text-xs">{{ $label }}</span>
    @endif

    {{-- Not a live region: announcing every second would drown out everything else. It reads when reached. --}}
    <time datetime="{{ $now->toIso8601String() }}" class="{{ $sizes[$size] }} text-foreground leading-none font-semibold tracking-tight tabular-nums">
        <span data-clock-time>{{ $now->format($hour12 ? 'g:i' : 'H:i') }}{{ $seconds ? $now->format(':s') : '' }}</span>
        @if ($hour12)
            <span data-clock-period class="text-[0.45em] font-medium tracking-normal">{{ $now->format('A') }}</span>
        @endif
    </time>

    @if ($date)
        <span data-clock-date class="text-foreground/60 text-sm">{{ $now->format('l, j F') }}</span>
    @endif
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/stopwatch.blade.php Show
stopwatch.blade.php
@props([
    // stopwatch counts up from zero; timer counts down from duration.
    'mode' => 'stopwatch',
    // Timer length in seconds, e.g. 1500 for 25 minutes.
    'duration' => 60,
    // Small text above the time, and its name for screen readers.
    'label' => null,
    // Announced, and shown, when a timer reaches zero.
    'done' => "Time's up",
    // How big the time is: sm, md or lg.
    'size' => 'md',
])

@php
    $sizes = ['sm' => 'text-2xl', 'md' => 'text-4xl', 'lg' => 'text-6xl'];
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($mode, ['stopwatch', 'timer'], true)) {
        throw new \InvalidArgumentException("Unknown mode [{$mode}] for <x-widget.clock.stopwatch>. Use one of: stopwatch, timer.");
    }
    if (! array_key_exists($size, $sizes)) {
        throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.clock.stopwatch>. Use one of: ".implode(', ', array_keys($sizes)).'.');
    }
    $timer = $mode === 'timer';
    $duration = max(1, (int) $duration);
    // Same format as resources/js/widget/clock: m:ss (h:mm:ss past an hour), plus tenths on a stopwatch.
    $start = $timer
        ? ($duration >= 3600 ? sprintf('%d:%02d:%02d', intdiv($duration, 3600), intdiv($duration % 3600, 60), $duration % 60) : sprintf('%d:%02d', intdiv($duration, 60), $duration % 60))
        : '0:00.0';
    $numbers = $sizes[$size];
@endphp

{{-- wire:ignore: all of it is the browser's (running or not, the time, the button's label), and a Livewire render would put
     back the server's 0:00.0 and Start, stopping it. So its props are read once, when it first appears. --}}
<div
    wire:ignore
    data-clock="{{ $timer ? 'timer' : 'stopwatch' }}"
    @if ($timer) data-duration="{{ $duration }}" data-done="{{ $done }}" @endif
    {{ $attributes->class(['group/clock inline-flex flex-col items-center gap-3']) }}
>
    @if ($label)
        <span class="text-foreground/60 text-xs">{{ $label }}</span>
    @endif

    {{-- Not a live region; it reads when reached. The status line below announces the end of a timer. --}}
    <div role="timer" @if ($label) aria-label="{{ $label }}" @endif data-clock-display class="{{ $numbers }} text-foreground leading-none font-semibold tracking-tight tabular-nums group-data-finished/clock:text-error">{{ $start }}</div>

    <div class="flex gap-2">
        <x-widget.button size="sm" data-clock-toggle class="min-w-24">
            <x-widget.icon name="play" class="size-4 group-data-running/clock:hidden" />
            <x-widget.icon name="pause" class="hidden size-4 group-data-running/clock:block" />
            <span data-clock-toggle-label>Start</span>
        </x-widget.button>
        <x-widget.button size="sm" variant="neutral" data-clock-reset>
            <x-widget.icon name="rotate-ccw" class="size-4" />
            Reset
        </x-widget.button>
    </div>

    <p role="status" data-clock-done class="text-error text-sm font-medium"></p>
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/world.blade.php Show
world.blade.php
@props([
    // ['London' => 'Europe/London', 'Tokyo' => 'Asia/Tokyo'], or a list of ['label' => …, 'timezone' => …].
    'zones' => [],
    // 12-hour time with AM or PM, instead of 24-hour.
    'hour12' => false,
    // The list's name for screen readers.
    'label' => 'World clocks',
])

@php
    $zones = collect($zones)->map(fn (mixed $zone, int|string $key): array => is_array($zone)
        ? ['label' => (string) $zone['label'], 'timezone' => (string) $zone['timezone']]
        : ['label' => (string) $key, 'timezone' => (string) $zone])->values();

    // Offsets and "tomorrow" are relative to the visitor, whom only the browser knows; the server's first
    // paint uses the app's timezone and resources/js/widget/clock corrects it straight away.
    $here = now(config('app.timezone'));
    $relative = function (\Carbon\CarbonInterface $there) use ($here): string {
        $minutes = $there->utcOffset() - $here->utcOffset();
        if ($minutes === 0) {
            return 'Same time';
        }
        $sign = $minutes > 0 ? '+' : '−';
        $minutes = abs($minutes);

        return $sign.intdiv($minutes, 60).'h'.($minutes % 60 ? ' '.($minutes % 60).'m' : '');
    };
    $day = fn (\Carbon\CarbonInterface $there): ?string => match ($there->toDateString() <=> $here->toDateString()) {
        1 => 'Tomorrow',
        -1 => 'Yesterday',
        default => null,
    };
@endphp

<ul aria-label="{{ $label }}" {{ $attributes->class(['grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]']) }}>
    @foreach ($zones as $zone)
        @php
            $there = now($zone['timezone']);
            $night = $there->hour < 6 || $there->hour >= 18;
            $dayHint = $day($there);
        @endphp
        <li
            data-clock="world"
            data-timezone="{{ $zone['timezone'] }}"
            @if ($hour12) data-hour12 @endif
            class="border-line bg-surface flex items-start justify-between gap-3 rounded-2xl border p-4"
        >
            <div class="flex min-w-0 flex-col gap-1">
                <span class="text-foreground truncate text-sm font-medium">{{ $zone['label'] }}</span>
                <time datetime="{{ $there->toIso8601String() }}" class="text-foreground text-2xl leading-none font-semibold tabular-nums">
                    <span data-clock-time>{{ $there->format($hour12 ? 'g:i' : 'H:i') }}</span>
                    @if ($hour12)
                        <span data-clock-period class="text-xs font-medium">{{ $there->format('A') }}</span>
                    @endif
                </time>
                <span class="text-foreground/60 text-xs">
                    <span data-clock-offset>{{ $relative($there) }}</span><span data-clock-day>{{ $dayHint ? ', '.$dayHint : '' }}</span>
                </span>
            </div>
            {{-- Day or night there (6:00 to 18:00 counts as day); the time already says it for screen readers. --}}
            <span data-clock-daylight aria-hidden="true" class="text-foreground/50 shrink-0">
                <x-widget.icon name="sun" :class="$night ? 'size-5 hidden' : 'size-5'" data-clock-sun />
                <x-widget.icon name="moon" :class="$night ? 'size-5' : 'size-5 hidden'" data-clock-moon />
            </span>
        </li>
    @endforeach
</ul><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/js/widget/clock/index.js Show
index.js
// Drives <x-widget.clock> and its variants. The server paints the first frame; this keeps them ticking.
// Every clock reads the device time and formats it for its own timezone (data-timezone, set by the Blade views).
// Events: clock:finished on a countdown or timer when it reaches zero (bubbles).

const LOCALE = 'en-US';
const formatters = new Map();

// Intl formatters are costly to build, so one per timezone/setting.
function formatter(timeZone, options) {
    const key = `${timeZone ?? ''}|${JSON.stringify(options)}`;
    if (!formatters.has(key)) {
        formatters.set(key, new Intl.DateTimeFormat(LOCALE, { ...options, ...(timeZone ? { timeZone } : {}) }));
    }

    return formatters.get(key);
}

// The wall-clock parts of `date` in a timezone: { year, month, day, hour, minute, second, weekday, monthName }.
function partsIn(date, timeZone) {
    const parts = {};
    for (const { type, value } of formatter(timeZone, {
        year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric',
        weekday: 'long', hourCycle: 'h23',
    }).formatToParts(date)) {
        parts[type] = value;
    }
    parts.monthName = formatter(timeZone, { month: 'long' }).format(date);

    return {
        year: Number(parts.year), month: Number(parts.month), day: Number(parts.day),
        hour: Number(parts.hour) % 24, minute: Number(parts.minute), second: Number(parts.second),
        weekday: parts.weekday, monthName: parts.monthName,
    };
}

const pad = (n) => String(n).padStart(2, '0');

// "09:41" / "9:41" + "AM", matching what the Blade views print.
function timeText({ hour, minute, second }, { hour12 = false, seconds = false } = {}) {
    const h = hour12 ? String(hour % 12 || 12) : pad(hour);

    return { time: `${h}:${pad(minute)}${seconds ? `:${pad(second)}` : ''}`, period: hour < 12 ? 'AM' : 'PM' };
}

// Minutes the timezone is ahead of UTC at `date`.
function offsetMinutes(date, timeZone) {
    const p = partsIn(date, timeZone);

    return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - date.getTime()) / 60000 / 15) * 15;
}

const setText = (el, text) => {
    if (el && el.textContent !== text) {
        el.textContent = text;
    }
};

// --- Clocks: digital, analog, world -------------------------------------------------------------------

function tickDigital(el, now) {
    const p = partsIn(now, el.dataset.timezone);
    const { time, period } = timeText(p, { hour12: 'hour12' in el.dataset, seconds: 'seconds' in el.dataset });
    setText(el.querySelector('[data-clock-time]'), time);
    setText(el.querySelector('[data-clock-period]'), period);
    setText(el.querySelector('[data-clock-date]'), `${p.weekday}, ${p.day} ${p.monthName}`);
    el.querySelector('time')?.setAttribute('datetime', now.toISOString());
}

function tickAnalog(el, now) {
    const p = partsIn(now, el.dataset.timezone);
    const angles = { hour: (p.hour % 12) * 30 + p.minute * 0.5, minute: p.minute * 6 + p.second * 0.1, second: p.second * 6 };
    for (const [name, angle] of Object.entries(angles)) {
        const hand = el.querySelector(`[data-clock-hand="${name}"]`);
        if (hand) {
            // The server's first frame uses the SVG attribute rotate(a 50 50); CSS rotate() has no centre argument,
            // so give it the clock's centre as its origin. Set from here, which a Content Security Policy allows.
            hand.style.transformOrigin = '50px 50px';
            hand.style.transform = `rotate(${angle}deg)`;
        }
    }
    const { time, period } = timeText(p, { hour12: true });
    setText(el.querySelector('[data-clock-spoken]'), `${time} ${period}`);
}

function tickWorld(el, now) {
    const p = partsIn(now, el.dataset.timezone);
    const { time, period } = timeText(p, { hour12: 'hour12' in el.dataset });
    setText(el.querySelector('[data-clock-time]'), time);
    setText(el.querySelector('[data-clock-period]'), period);

    // Relative to the visitor: "+8h", "−5h 30m", "Same time"; and whether it's already tomorrow there.
    const here = partsIn(now);
    let diff = offsetMinutes(now, el.dataset.timezone) - offsetMinutes(now);
    const sign = diff > 0 ? '+' : '−';
    diff = Math.abs(diff);
    setText(el.querySelector('[data-clock-offset]'), diff === 0 ? 'Same time' : `${sign}${Math.floor(diff / 60)}h${diff % 60 ? ` ${diff % 60}m` : ''}`);
    const dayDiff = Math.sign(Date.UTC(p.year, p.month - 1, p.day) - Date.UTC(here.year, here.month - 1, here.day));
    setText(el.querySelector('[data-clock-day]'), dayDiff > 0 ? ', Tomorrow' : dayDiff < 0 ? ', Yesterday' : '');

    const night = p.hour < 6 || p.hour >= 18;
    el.querySelector('[data-clock-sun]')?.classList.toggle('hidden', night);
    el.querySelector('[data-clock-moon]')?.classList.toggle('hidden', !night);
}

// --- Countdown ------------------------------------------------------------------------------------------

// Server time minus device time, per countdown, measured when the page loaded. Only a big difference counts: the
// render-to-script delay (a slow network, a page served from cache) also shows up here, and treating that as
// skew would make the countdown run late by it. A device clock that's really off is usually off by minutes.
const skew = new WeakMap();
const SKEW_THRESHOLD_MS = 60_000;

function tickCountdown(el) {
    if (!skew.has(el)) {
        const measured = Number(el.dataset.now) - Date.now();
        skew.set(el, Math.abs(measured) > SKEW_THRESHOLD_MS ? measured : 0);
    }
    // Rounded up, as the server does, so the first tick doesn't jump a second up and 0 means the time is up.
    const left = Math.max(0, Math.ceil((Number(el.dataset.to) - (Date.now() + skew.get(el))) / 1000));
    const units = { days: Math.floor(left / 86400), hours: Math.floor((left % 86400) / 3600), minutes: Math.floor((left % 3600) / 60), seconds: left % 60 };
    const shown = [];
    for (const [unit, n] of Object.entries(units)) {
        const cell = el.querySelector(`[data-clock-unit="${unit}"]`);
        if (cell) {
            setText(cell, unit === 'days' ? String(n) : pad(n));
            if (n > 0) {
                shown.push(`${n} ${n === 1 ? unit.slice(0, -1) : unit}`);
            }
        }
    }
    // Hours absorb the days when the countdown started with less than a day to go (no days cell).
    if (!el.querySelector('[data-clock-unit="days"]')) {
        setText(el.querySelector('[data-clock-unit="hours"]'), pad(units.hours + units.days * 24));
    }
    // Spoken without the zero units, like the server.
    setText(el.querySelector('[data-clock-spoken]'), `${shown.join(', ') || '0 seconds'} left`);

    if (left === 0 && !('finished' in el.dataset)) {
        finish(el);
    }
}

function finish(el) {
    el.dataset.finished = '';
    setText(el.querySelector('[data-clock-done]'), el.dataset.done ?? '');
    el.dispatchEvent(new CustomEvent('clock:finished', { bubbles: true }));
}

// --- Stopwatch and timer --------------------------------------------------------------------------------

// Per element: { elapsed (ms before the current run), startedAt (performance.now() of the current run, or null) }.
const runs = new WeakMap();
// Per running timer: the timeout that ends it even in a background tab (see start()).
const finishTimers = new WeakMap();

function elapsedOf(el) {
    const run = runs.get(el) ?? { elapsed: 0, startedAt: null };

    return run.elapsed + (run.startedAt === null ? 0 : performance.now() - run.startedAt);
}

function durationText(ms, tenths) {
    const total = tenths ? Math.floor(ms / 100) / 10 : Math.ceil(ms / 1000);
    const whole = Math.floor(total);
    const h = Math.floor(whole / 3600);
    const m = Math.floor((whole % 3600) / 60);
    const s = whole % 60;
    const clock = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;

    return tenths ? `${clock}.${Math.floor((total * 10) % 10)}` : clock;
}

function renderStopwatch(el) {
    const timer = el.dataset.clock === 'timer';
    const elapsed = elapsedOf(el);
    const display = el.querySelector('[data-clock-display]');
    if (!timer) {
        setText(display, durationText(elapsed, true));

        return;
    }
    const left = Math.max(0, Number(el.dataset.duration) * 1000 - elapsed);
    setText(display, durationText(left, false));
    if (left === 0 && !('finished' in el.dataset)) {
        pause(el);
        finish(el);
        setText(el.querySelector('[data-clock-toggle-label]'), 'Start');
    }
}

function start(el) {
    if ('finished' in el.dataset) {
        reset(el);
    }
    const run = runs.get(el) ?? { elapsed: 0, startedAt: null };
    run.startedAt = performance.now();
    runs.set(el, run);
    el.dataset.running = '';
    setText(el.querySelector('[data-clock-toggle-label]'), 'Pause');
    loop();
    // Browsers pause animation frames in a background tab, so a timer's end is also scheduled with a timeout:
    // clock:finished and "Time's up" then happen on time, not when the tab is looked at again.
    if (el.dataset.clock === 'timer') {
        clearTimeout(finishTimers.get(el));
        const left = Math.max(0, Number(el.dataset.duration) * 1000 - elapsedOf(el));
        finishTimers.set(el, setTimeout(() => renderStopwatch(el), left + 20));
    }
}

function pause(el) {
    clearTimeout(finishTimers.get(el));
    const run = runs.get(el);
    if (run?.startedAt != null) {
        run.elapsed += performance.now() - run.startedAt;
        run.startedAt = null;
    }
    delete el.dataset.running;
    setText(el.querySelector('[data-clock-toggle-label]'), elapsedOf(el) > 0 ? 'Resume' : 'Start');
}

function reset(el) {
    clearTimeout(finishTimers.get(el));
    runs.set(el, { elapsed: 0, startedAt: null });
    delete el.dataset.running;
    delete el.dataset.finished;
    setText(el.querySelector('[data-clock-done]'), '');
    setText(el.querySelector('[data-clock-toggle-label]'), 'Start');
    renderStopwatch(el);
}

// Runs every frame only while something is running; the display changes every tenth of a second.
let looping = false;
function loop() {
    if (looping) {
        return;
    }
    looping = true;
    const frame = () => {
        const running = document.querySelectorAll('[data-clock="stopwatch"][data-running], [data-clock="timer"][data-running]');
        running.forEach(renderStopwatch);
        if (running.length) {
            requestAnimationFrame(frame);
        } else {
            looping = false;
        }
    };
    requestAnimationFrame(frame);
}

document.addEventListener('click', (event) => {
    const toggle = event.target.closest?.('[data-clock-toggle]');
    const resetButton = event.target.closest?.('[data-clock-reset]');
    const el = (toggle ?? resetButton)?.closest('[data-clock="stopwatch"], [data-clock="timer"]');
    if (!el) {
        return;
    }
    if (resetButton) {
        reset(el);
    } else if ('running' in el.dataset) {
        pause(el);
    } else {
        start(el);
    }
});

// --- One tick for all the clocks, on the second ---------------------------------------------------------

function tick() {
    const now = new Date();
    document.querySelectorAll('[data-clock="digital"]').forEach((el) => tickDigital(el, now));
    document.querySelectorAll('[data-clock="analog"]').forEach((el) => tickAnalog(el, now));
    document.querySelectorAll('[data-clock="world"]').forEach((el) => tickWorld(el, now));
    document.querySelectorAll('[data-clock="countdown"]:not([data-finished])').forEach(tickCountdown);
}

tick();
// Line the ticks up with the device's second, so seconds change together and on time.
setTimeout(() => {
    tick();
    setInterval(tick, 1000);
}, 1000 - (Date.now() % 1000));

export const clock = { start, pause, reset };

// A Livewire render puts back the server's first frame: its time, and world clocks' offsets from the app's timezone
// rather than the visitor's. Tick straight away, so that frame never shows. (Stopwatches and timers are wire:ignore'd.)
const hook = (Livewire) => Livewire.hook('morphed', () => tick());
if (window.Livewire) {
    hook(window.Livewire);
} else {
    document.addEventListener('livewire:init', () => hook(window.Livewire));
}