Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Check, ChevronsUpDown, Globe, LocateFixed, Search } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Zone table
 * ------------------------------------------------------------------ */

export interface TimezoneOption {
  /** IANA zone id — this is the component's value, e.g. "Europe/Berlin". */
  value: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/timezone-picker.json

Prompt

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 formatToParts and re-interpreting it through Date.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:30 and +5:30), and the query rather than the haystack is normalized so gmt+8 and +8 agree while a bare gmt still means "zones actually on GMT" instead of matching all ninety.
  • The clock is state, not a render-time readDate.now() in render breaks render purity and desyncs the server from the first client paint. The instant starts as null (rendering --:-- on both sides), then arrives from a requestAnimationFrame and is refreshed by a setInterval — async callbacks, where setState is legal. Passing now skips the timer entirely and pins every picker on the page to one shared moment.
  • Ambient zone in the handler onlyIntl.DateTimeFormat().resolvedOptions().timeZone is 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: hidden ancestors are decoration and are escaped, not obeyed; overflow: auto | scroll ancestors 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: fixed panel 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's scrollTop put 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.

On This Page