Display
World Clock
Several time zones on one card — rows ticking on one shared minute timer, with Intl-derived offsets and DST, yesterday/tomorrow markers, and a working-hours band that shows the overlap with your own day.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/world-clock.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "WorldClock" component: one card, one row
per time zone, every row rendered from a single instant. lucide-react for the
icons, Intl.DateTimeFormat for every string and every number, no date library.
Contract
- forwardRef<HTMLDivElement> — the ref and every native div prop land on the
card, className merges into it.
- Props extend React.HTMLAttributes<HTMLDivElement> and add:
zones: { timeZone: string; label?: string; meta?: string;
workingHours?: { start: number; end: number } }[],
homeTimeZone?: string (controlled) / defaultHomeTimeZone?: string,
onHomeTimeZoneChange?: (timeZone: string) => void,
now?: Date | number, locale?: string (default "en-US"),
hour12?: boolean (default false),
workingHours?: { start: number; end: number } (default 9–18, in each zone's
own local hours), band?: boolean (default true),
defaultPreviewHour?: number, label?: string (accessible name of the row list).
- The "home" zone is the reference: day markers, the band's anchor and the
overlap count are all measured against it. A home id that is missing, unknown
to this runtime or simply not in `zones` falls back to the first usable row
instead of blanking the card. Exactly one row is the home row — it is matched
by index, so listing the same zone twice highlights the first of the two
rather than both.
- `label` defaults to the last segment of the id with underscores replaced —
"America/New_York" reads as "New York" — so a zone list needs no labels to be
legible.
- No zod contract and no mock: `zones` is the whole input, and a zone list is
configuration, not fetched data. There is no loading or error state to render.
Behavior — the clock
- Render never calls Date.now(). The instant arrives one of two ways: the `now`
prop, or useSyncExternalStore over a module-level clock whose
getServerSnapshot returns null. With a server snapshot of null, SSR and the
first hydration pass both draw placeholders ("--:--", an em dash, a flat band)
and React fills in the real time by itself on the next store read — no
mismatch, no `mounted` flag. Reading the clock during render would make the
server and the browser disagree on the minute and blow up hydration.
- Cache the snapshot in a module variable. Returning a fresh Date.now() from
getSnapshot makes React see a changed store on every render — at best a wasted
pass, at worst "The result of getSnapshot should be cached to avoid an
infinite loop".
- One timer for every row, every card and every instance: subscribers share a
single setTimeout that re-aims at the next minute boundary (60000 - now %
60000, plus ~250ms of slack so an early fire does not render the minute that
is about to end and then sit on it). The last unsubscriber clears it, and
while `now` is pinned the timer never starts at all.
- Offsets are never stored. Read the zone's wall clock back with an en-US /
h23 formatToParts, re-interpret those parts as if they were UTC, and subtract
the real instant (floored to whole seconds, because formatToParts has no
milliseconds) — that difference is the offset in minutes. Deriving it is what
keeps DST, the half-hour zones (India, Newfoundland) and the quarter-hour ones
(Nepal, Chatham) correct, and correct again when the rules change.
- "Is it on summer time?" is the same trick twice: compare the current offset to
the smaller of the zone's mid-January and mid-July offsets, which works in both
hemispheres. Cache that per zone+year; cache the formatters per zone+locale.
- The day marker compares calendar days, not clocks: build a day number from
each zone's Y/M/D parts and diff it against the home row's — 0 shows nothing,
±1 shows "Tomorrow" / "Yesterday", anything larger shows "+2 days".
Behavior — the overlap band
- The band is one reference day, 24 cells, anchored at the home zone's local
midnight (found by walking back the wall time elapsed since it, not by
arithmetic on the date).
- Every cell is re-read from Intl at its own instant instead of adding hours to
one offset, so a band that straddles a DST change bends with it rather than
shearing by an hour.
- Three cell states: working in this zone *and* at home (the overlap, strongest
fill), working only here (soft fill), off hours (muted). Count the overlap and
put it in the row's accessible name — "3 working hours overlapping Berlin", or
"no working hours overlapping Berlin", which is a real answer and not an empty
strip. `end <= start` wraps past midnight, so a night shift is expressible;
start === end means "never working"; hours are clamped to 0..24 integers.
- A hairline marks the current instant across every band, positioned as a
percentage of the day.
Behavior — scrubbing the day
- Pointing at a band previews that hour: every row switches to it, so "what time
is it everywhere at 16:00 my time" is one gesture. Hover is transient (it
clears on pointerleave); the arrow keys pin it (it persists, and a chip plus a
"Back to now" button appear). displayedSlot = hover ?? pinned, and the pointer
handler only setStates when the slot index actually changes.
- Ignore touch pointermove: a drag there belongs to the scroller, and the tap
already selected the row.
- "Back to now" unmounts itself the moment it succeeds, so it must hand focus to
a deliberate successor — focus the home row *first*, while the button still
exists, then clear the state. Otherwise focus lands on <body> and a keyboard
user starts over. The release is a one-shot: read AND write the pin ref inside
the same synchronous block, so an Escape and a click arriving together cannot
run the handoff twice.
Behavior — keyboard and ARIA
- The rows are a single-select listbox: role="listbox" with an accessible name,
each row a <button role="option"> with aria-selected, and a roving tabindex so
the whole list is one Tab stop.
- ArrowDown / ArrowUp move by one row and select as they go (selection follows
focus, clamped at the ends — a listbox does not wrap); Home / End jump to the
first / last row; Enter and Space select explicitly; ArrowLeft / ArrowRight
scrub the previewed hour by one slot, clamped to the day; Escape releases the
pin and, when it was pressed on the chip, restores focus to the reference row.
Left / Right are free for scrubbing precisely because this is a listbox and
not a radiogroup.
- A zone id Intl rejects (a typo, a stale export, an old tzdata) is a refusal,
not a crash: remember the constructor's RangeError, render the row inert with
aria-disabled, the id spelled out and a reason. It stays focusable so it can
be perceived, and every guard lives in the handler — selecting it is a no-op,
because a zone with no offset cannot be the thing everything else is measured
against. Never the native disabled attribute here: the user may be standing on
the row, and the browser would blur it to <body>.
- The band is aria-hidden decoration with pointer handlers; its content reaches
assistive tech through the row's aria-label, which reads label, meta, time,
offset, day marker, DST and the overlap sentence.
- A permanently mounted polite live region (role="status", visually hidden)
announces the pinned preview, because pinning changes every row while focus
stays put. It never contains the ticking time — that would interrupt every
minute — and it follows the pin, not the hover, so a mouse does not make it
chatter.
- Keep the consumer's onKeyDown first and bail on defaultPrevented; only
preventDefault the keys actually handled, and only stopPropagation on the
Escape that really released a pin.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, bg-accent
for the selected row and the preview chip, bg-primary for overlap cells and
bg-primary/35 for working-only cells, bg-muted for off hours, bg-foreground/70
for the now hairline, text-muted-foreground for every secondary line, and
text-destructive for the unknown-zone reason.
- cn() merges every className; focus-visible:ring-2 ring-ring with an offset on
every row and on the chip button; tabular-nums everywhere a digit column has
to line up.
- Motion is decoration: colour and height transitions only, all behind
motion-reduce:transition-none, plus a pulse on the live dot behind
motion-reduce:animate-none. With motion off the clock still ticks, the band
still fills and the preview still moves.
- The band holds a constant height so a preview cell can grow without shifting
the row, and the axis (00 / 06 / 12 / 18, labelled from Intl at those slots so
it stays truthful) carries the same horizontal padding as the rows so the
columns line up.
Customization levers
- Density: rows are px-3 py-2 with a size-1.5 band cell — drop the band to a
1px hairline for a sidebar strip, or raise the time to text-xl for a wall
display. Nothing is measured in JS, so type and spacing are free to move.
- Sub-blocks are independent: delete the header (globe, date, live dot), the
DST chip, the day/night icon or the whole band (`band={false}` already ships
as a compact roster) without touching the clock.
- Band shape: 24 one-hour cells is a constant. Halve it for a 12-hour planner,
or split each hour into two cells for half-hour zones — only the anchor and
the slot length change.
- Working windows: one default for the card, per-row overrides for shifts, and
a wrapping window for night work. To colour by role rather than by overlap,
swap the three cell classes for var(--chart-1..5).
- Time text: `hour12` and `locale` decide the clock, the weekday and the header
date; keep locale explicit rather than undefined, or the server's locale and
the visitor's will render different strings into the same HTML.
- The reference zone can be controlled from the page (homeTimeZone +
onHomeTimeZoneChange) so two cards, a map or a form can share one "home".
- `now` accepts anything you already have: a pinned instant for tests and
screenshots, or a scrubber the page owns — while it is set, the component's
own timer stays off.Concepts
- Injected instant — every row is the same moment rendered N ways, and that moment is either a prop or a cached store snapshot; render never reads the clock, which is what lets the card be server-rendered and screenshot-stable instead of a hydration mismatch waiting to happen.
- One shared minute timer — rows are not independent clocks: a single timeout, re-aimed at the next minute boundary rather than looping every 60s, wakes every subscriber and is cleared by the last one to leave.
- Derived offset, not a stored one — the GMT label, the day marker and the band all come from reading a zone's wall clock back out of
Intlat a specific instant, so daylight saving, half-hour and quarter-hour zones are correct by construction rather than by a table someone has to maintain. - Overlap as a first-class answer — the band's strong cells are the hours both zones are working, and the count reaches screen readers as a sentence, so "no overlap at all" is stated instead of being an empty strip you have to interpret.
- Hover previews, arrows pin — the same scrub has a transient mode for the pointer and a persistent one for the keyboard; only the persistent one grows an exit affordance, and that affordance hands focus back before it unmounts itself.
- Refusal over crash — an id this runtime's tzdata does not know keeps its row, spelled out and inert via
aria-disabledplus a handler guard, because a zone with no offset can never be the reference everything else is measured against.
Barcode
A vector Code 128 / EAN-13 renderer — automatic subset switching, a real check digit, spec quiet zones, and a refusal that names what cannot be encoded.
Spinner
One indeterminate loader, seven shapes — three boxed (ring, dual-ring, bars) and four inline dot rows (dots, ellipsis, bounce, wave), colored by a single tone prop or plain currentColor.