<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
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.
<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.
Show code Hide code
<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.
Show code Hide code
<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 Hide code
<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.
Show code Hide code
<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 Hide code
<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.
Show code Hide code
<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". |
| seconds |
false
|
|
| date |
false
|
A line under the time: "Friday, 27 September". |
| label |
null
|
|
| size |
'md'
|
<x-widget.clock.analog>
| Prop | Default | Description |
|---|---|---|
| timezone | Required | Required: an IANA timezone like "Europe/London". |
| seconds |
true
|
|
| numbers |
false
|
12, 3, 6 and 9 on the face. |
| label |
null
|
|
| size |
'md'
|
<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'
|
|
| 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'
|
<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
|
|
| done |
"Time's up"
|
Announced, and shown, when a timer reaches zero. |
| size |
'md'
|
<x-widget.clock.world>
| Prop | Default | Description |
|---|---|---|
| zones |
[]
|
['London' => 'Europe/London', 'Tokyo' => 'Asia/Tokyo'], or a list of ['label' => …, 'timezone' => …]. |
| label |
'World clocks'
|
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
@props([
// Required: an IANA timezone like "Europe/London".
'timezone',
'seconds' => true,
// 12, 3, 6 and 9 on the face.
'numbers' => false,
'label' => null,
'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'];
// 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] ?? $sizes['md'] }}">
<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
@props([
// When it ends: a date string ("2026-12-31 23:59") in the app's timezone, or a Carbon/DateTime instance.
'to',
'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',
'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(', ');
$inline = $variant === 'inline';
// Phones share the width between the boxes, so md and lg step down a size there.
$numbers = ['sm' => 'text-xl', 'md' => 'text-2xl sm:text-3xl', 'lg' => 'text-4xl sm:text-5xl'][$size] ?? 'text-2xl sm:text-3xl';
@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" aria-label="{{ $label }}" 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
@props([
// Required: an IANA timezone like "Europe/London".
'timezone',
'hour12' => false,
'seconds' => false,
// A line under the time: "Friday, 27 September".
'date' => false,
'label' => null,
'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'];
@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] ?? $sizes['md'] }} 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
@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,
'label' => null,
// Announced, and shown, when a timer reaches zero.
'done' => "Time's up",
'size' => 'md',
])
@php
$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 = ['sm' => 'text-2xl', 'md' => 'text-4xl', 'lg' => 'text-6xl'][$size] ?? 'text-4xl';
@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
@props([
// ['London' => 'Europe/London', 'Tokyo' => 'Asia/Tokyo'], or a list of ['label' => …, 'timezone' => …].
'zones' => [],
'hour12' => false,
'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
// 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));
}