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…

"use client"

import * as React from "react"
import { Globe, Moon, Sun } from "lucide-react"

import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Time math
 *
 * Nothing here stores an offset. Every number — the GMT label, the day
 * marker, which hour of the band counts as working — is read back out of
 * `Intl` at a specific instant, which is the only way DST, the half-hour
 * zones (India, Newfoundland) and the quarter-hour ones (Nepal, Chatham)

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/world-clock.json

Prompt

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 Intl at 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-disabled plus a handler guard, because a zone with no offset can never be the reference everything else is measured against.

On This Page