Timezone Picker
A searchable IANA time-zone field — ~90 zones grouped Popular / All, each row showing that zone's live local time and GMT offset, with a portalled panel that no overflow-hidden card can clip.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/timezone-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TimezonePicker" component (React 19 +
react-dom + lucide-react only; no date library, no positioning library, no
Radix — Intl does all the time math).
Contract
- export const TimezonePicker = React.forwardRef<HTMLDivElement,
TimezonePickerProps>, where TimezonePickerProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">:
- value? / defaultValue? : string — an IANA zone id ("Europe/Berlin").
Controlled when `value` is passed, uncontrolled otherwise.
- onValueChange?: (zone: string) => void — fires on every commit: row click,
Enter on the highlighted row, and "Use my time zone".
- zones?: TimezoneOption[] (defaults to the exported TIMEZONES table)
- now?: Date — pins the clock to one instant instead of ticking.
- refreshInterval?: number (default 30000; 0 freezes the self-tick)
- locale?: string (default "en-US"), hour12?: boolean (default false)
- placeholder? / searchPlaceholder? / emptyText? / disabled? /
detectable? (default true — shows the "Use my time zone" footer button)
- className merged onto the root through cn(); remaining props spread there.
- export interface TimezoneOption { value (IANA id), city, region,
aliases?: string[], popular?: boolean }.
- export const TIMEZONES: TimezoneOption[] — a curated ~90-entry slice of the
IANA database (one row per place people actually pick, not the 400+ full list
which is mostly aliases and uninhabited islands), with ~14 flagged `popular`.
- NO offset is ever stored in the table. Offsets are derived from the clock, and
that is the whole point: DST, the half-hour zones (India, Iran, Newfoundland)
and the quarter-hour ones (Nepal +05:45, Chatham +12:45/+13:45) all come out
right, and stay right when a government changes the rules.
- `aliases` are search-only and never rendered. A static table cannot know
whether Berlin is on CET or CEST *today*, so printing a stored abbreviation
would be a lie half the year; the row shows the computed GMT offset instead
and the abbreviations only feed the filter.
Behavior — time math
- Offset in minutes at an instant: format the instant into the target zone with
Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year, month, day,
hour, minute, second }), read it back with formatToParts, rebuild it through
Date.UTC as if that wall clock were UTC, and subtract the real instant floored
to whole seconds. The flooring matters: formatToParts has no milliseconds, and
without it the subtraction carries a sub-second error that can round a zone
into the wrong minute. Full date parts (not just the hour) are required so the
day/month/year rollover across the date line is handled by Date.UTC.
- Use hourCycle: "h23", not hour12: false — the latter still yields "24" for
midnight in some engines, which shifts the derived offset by a day.
- Row clock = a second formatter, { timeZone, hourCycle: hour12 ? "h12" : "h23",
hour: hour12 ? "numeric" : "2-digit", minute: "2-digit" } — 24-hour stays
zero-padded so the column lines up, 12-hour does not, because "02:32 PM" reads
like a typo to anyone who uses it daily. Never call Intl.* with an undefined
locale: a formatted string that depends on the ambient locale differs between
the server render and the visitor's browser and breaks hydration.
- Both formatters are memoised in a module-level Map keyed by
zone|locale|hourCycle — ~180 instances for the default table, far too many to
rebuild every tick. Intl formatters are stateless, so the shared cache is safe.
A bad zone id makes the constructor throw RangeError (a typo, an old tzdata
that has never heard of "Europe/Kyiv"), so the cache also remembers `null` for
rejected ids: those rows render with a dash instead of taking the picker down.
- Offset display: "GMT" for zero, otherwise "GMT+05:30" / "GMT-03:30".
Behavior — the clock (this is where hooks rules bite)
- Render must never call Date.now() / new Date(): react-hooks/purity forbids it,
and it would desync SSR from the first client paint. Hold the instant in state
as `number | null`, starting at null, and fill it from a
requestAnimationFrame + setInterval pair inside an effect — both async
callbacks, so setState there is legal, unlike a synchronous write in the
effect body. null renders "--:--", which is what the server and the hydrating
frame agree on.
- `now` short-circuits all of that: when it is passed the timer never starts.
A non-finite Date falls back to the live clock rather than rendering NaN.
- Clock cost is gated on `open`: a closed picker computes exactly one row (its
own), not all ninety, on every tick.
Behavior — search, grouping, keyboard
- Each row carries a lowercase haystack: city + region + the IANA id (both raw
and with "/" and "_" turned into spaces) + aliases + offset spellings, padded
("+05:30") and short ("+5:30", "-8"). A query is split on whitespace and every
token must appear somewhere in the haystack, so "new york" and "cest" and
"+5:30" all work.
- Do NOT bake "gmt"/"utc" into the haystack next to the offset — every row would
then contain "gmt" and searching for it would return all ninety zones.
Normalize the *query* instead: strip a leading gmt/utc that is followed by a
sign, so "gmt+8" becomes "+8" while a bare "gmt" or "utc" still filters down to
the zones whose own name says so.
- Sort west to east by offset, tie-broken by city with an explicit locale, so
the offset column reads as a gradient. Then split into two sections whose
headings are module constants (POPULAR_LABEL / ALL_LABEL), driven by the
`popular` flag. A popular zone is NOT repeated below — two role="option" nodes
with the same value would confuse keyboard nav and screen readers alike.
- The current value always gets a row even when it is absent from the table: an
unknown id is synthesized into { city, region } by splitting on "/" and
un-escaping underscores ("America/Argentina/Buenos_Aires" → "Buenos Aires" in
"America / Argentina"), and pinned into Popular. Without it, picking an exotic
zone would leave the list with nothing checked and no way back.
- De-duplicate the incoming `zones` by id before rendering: a repeat produces two
rows with the same React key.
- Keyboard: the trigger opens on click, Enter/Space (it is a real <button>) and
Arrow keys. Inside the panel the search field owns ↑ ↓ Home End (move the
highlight), Enter (commit) and Escape (close and return focus, with
stopPropagation so a picker inside a dialog closes only itself). The
highlighted row is kept in view with scrollIntoView({ block: "nearest" }).
- The highlight is DERIVED, not stored: an explicit index is used only while it
still points at a real row, otherwise it falls back to the checked row (empty
query) or the first result (while typing). Filtering therefore never has to
reach in and repair the highlight.
- "Use my time zone" reads Intl.DateTimeFormat().resolvedOptions().timeZone —
and this is the one place a bare, locale-less Intl constructor is correct,
because nothing is being formatted: that call is how you ask the runtime where
the visitor is. It must live inside the click handler; reading it during
render would make the server and the client disagree. If the runtime reports
nothing, or reports a zone the formatter rejects, show an inline failure
message instead of committing a value that would render as a dash.
Behavior — the floating panel
- The panel is rendered through createPortal into document.body with
position: fixed. An absolutely positioned panel inside an overflow-hidden card
is drawn INSIDE the clip: present in the DOM, invisible and unclickable.
A portalled panel is not a descendant of that card, so nothing can clip it.
- Boundary = the window intersected with every ancestor whose computed overflow
is auto|scroll. Ancestors that are merely overflow:hidden are deliberately NOT
boundaries — the portal already escaped them, and clamping the panel into a
decorative 144px card would trade an invisible panel for a crushed one.
- One synchronous pass: width → natural height → flip → cap → shift.
1. Width = max(trigger width, 280px) clamped to the boundary, and it is
written to the DOM *before* the height is read: a position:fixed panel with
no width shrink-wraps its widest row and would be measured at a width it
will never actually have.
2. Natural height = height with the inline max-height momentarily set to
"none". Measuring a panel already capped by the previous pass makes it
always look like it fits, which is how hand-rolled poppers end up
flip-flopping on every resize. Lifting the cap collapses the option list
and resets its scrollTop, so save and restore that too, or every reposition
yanks the user's scrolled list back to the top.
3. Flip above only when below cannot hold the panel AND above is genuinely
roomier, so a list that fits nowhere stays put and scrolls.
4. Cap max-height to the room that exists on the chosen side (floor 180px),
and shift-clamp `left` into the boundary.
- Measurement runs in a ResizeObserver callback observing the panel and the
root — RO fires once right after observe(), after layout and before paint, so
that first callback IS the initial measurement and no setState happens
synchronously in an effect body. Also reposition on capture-phase window
`scroll` (passive, rAF-throttled, ignoring scrolls that originate inside the
panel) and on `resize`; disconnect and remove everything on close.
- Guard the state write with a field-by-field equality check: applying the
computed max-height resizes the panel and re-fires the observer, and bailing
on an unchanged result turns that into one no-op instead of a loop.
- Until coordinates exist the panel carries opacity-0 (NOT visibility:hidden —
a hidden subtree cannot take focus, and the search field is focused the moment
the panel mounts).
- Dismissal: document `pointerdown` outside (not click, so the panel is gone
before the press turns into a click underneath) and `focusin` outside, since
Tab out of a portalled panel lands somewhere unrelated in DOM order. Neither
yanks focus back — the user already moved on. Escape and a commit do return
focus to the trigger, after an `isConnected` check, because committing a zone
routinely re-renders the surrounding form and focusing a detached node
silently drops focus onto <body>.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border /
border-input / bg-accent / text-accent-foreground / text-muted-foreground /
ring-ring / text-destructive. No hardcoded colors, no palette classes.
- Trigger: h-10 w-full rounded-md border, a globe icon, the city in medium
weight with the region muted beside it, and the live time over the GMT offset
right-aligned in tabular-nums so the digits do not jitter as they tick.
- Panel: rounded-md border bg-popover shadow-md, a search row with its own
border-b, a `max-h-64 min-h-0 flex-1 overflow-auto` list, and an optional
footer button. tabIndex={-1} on the list keeps Chromium from turning the
scroll container into its own tab stop.
- a11y: the trigger is a <button role="combobox"> with aria-haspopup="listbox",
aria-expanded and aria-controls (only while open, so the id always resolves).
The search input carries aria-autocomplete="list", aria-controls and
aria-activedescendant. The list is role="listbox" and rows are role="option"
with aria-selected. Section headings are <li role="presentation"> — a bare
<li> between the listbox and its options breaks the ownership chain and screen
readers announce an empty list. The check mark is aria-hidden; selection is
carried by aria-selected.
- Only colour and transition are animated, so prefers-reduced-motion has nothing
to disable beyond motion-reduce:transition-none; the panel appears instantly
rather than sliding.
- "use client" is required: state, effects, portal, DOM measurement.
Customization levers
- The table: pass `zones` to replace it entirely (offices, supported regions,
a per-tenant allow-list) — `region` doubles as any secondary line you want
("Engineering · 24 people"), and `aliases` as invisible search fodder. Or
import TIMEZONES and filter it: TIMEZONES.filter(z => z.value.startsWith("Europe/")).
- The two sections come from the `popular` flag, and their headings are the
POPULAR_LABEL / ALL_LABEL constants at the top of the file. Flag nothing and
everything lands under one heading; flag a per-user "recent zones" list and
rename POPULAR_LABEL to "Recents" and it is a recents section.
- Sorting: swap the comparator for a.city.localeCompare(b.city, locale) to get an
alphabetical list, or sort by |offset - myOffset| for a "closest to me" order.
- Clock: hour12 and locale control the format; refreshInterval controls the
cadence (60000 is plenty for HH:mm, 0 freezes it); `now` pins every picker on
the page to one shared instant, which is how you turn this into a
"what time is the meeting for everyone" widget.
- Density: rows are two-line (city over region, time over offset). Drop the
second line of each column for a compact single-line list, or raise the list
cap from max-h-64 for a taller panel. MIN_PANEL_WIDTH (280) and the 180px
height floor decide how small the panel may get before it just scrolls.
- Detection: detectable={false} hides the footer button entirely; or keep it and
swap the label for the detected id by resolving it once in an event handler
and storing it in state — never during render.
- Turning this into a multi-select "team clocks" panel: keep the row + clock
half, switch aria-selected to a checkbox column, and stop closing on commit.Concepts
- Derived offset, never stored — the table holds cities, not numbers. An offset is computed per row by formatting the current instant into the zone, reading the wall clock back with
formatToPartsand re-interpreting it throughDate.UTC. That single derivation is what makes DST, the half-hour zones (India, Newfoundland) and the quarter-hour ones (Nepal, Chatham) correct without a tz database in your bundle — and correct again next year when a government moves a boundary. - Aliases are search fodder, not labels — "CET" and "CEST" both live on the Berlin row so either spelling finds it, but neither is ever rendered: a static table cannot know which one is true today, and a wrong abbreviation is worse than no abbreviation. The row displays the offset it just computed instead.
- Offset as searchable text — people who cannot name a city can still name a number. Each row carries both spellings of its offset (
+05:30and+5:30), and the query rather than the haystack is normalized sogmt+8and+8agree while a baregmtstill means "zones actually on GMT" instead of matching all ninety. - The clock is state, not a render-time read —
Date.now()in render breaks render purity and desyncs the server from the first client paint. The instant starts asnull(rendering--:--on both sides), then arrives from arequestAnimationFrameand is refreshed by asetInterval— async callbacks, wheresetStateis legal. Passingnowskips the timer entirely and pins every picker on the page to one shared moment. - Ambient zone in the handler only —
Intl.DateTimeFormat().resolvedOptions().timeZoneis the one bare, locale-less Intl call that belongs here, because it formats nothing; it asks the runtime where the visitor is. Read during render it would be a hydration mismatch, so it is read on click, validated, and either committed or reported as a failure. It is never guessed silently on mount. - Portal before geometry — the cure for "the panel is in the DOM but I cannot click it" is not smarter math, it is not being a descendant of the thing that clips.
overflow: hiddenancestors are decoration and are escaped, not obeyed;overflow: auto | scrollancestors are real viewports and do clamp the panel. Treating both as boundaries trades an invisible panel for a crushed one. - Width before height — a
position: fixedpanel with no width shrink-wraps its widest row, so measuring its height first measures a shape it will never have. Width is derived from the trigger, written to the DOM, and only then is the natural height read with the max-height cap momentarily lifted (and the list'sscrollTopput back afterwards). - Derived highlight — the arrow keys write an index, but the index is only honoured while it still points at a live row. The moment filtering invalidates it, it falls back to the checked zone with an empty query, or the first hit while typing. Storing the corrected value instead would mean every keystroke has to repair state that render can just compute.
Repeater Field
A generic controlled array field — add, remove, move, and drag-sort rows of any shape, with min/max clamping and stable row ids that never remount a focused input.
Cron Input
A visual builder for five-field cron — presets, hour/minute dropdowns and weekday/month chips write the expression, pasting an expression parses straight back into them, with a plain-English summary and the next runs underneath.