{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timezone-select",
  "title": "Time Zone Select",
  "description": "A time zone picker: every IANA zone the browser knows, grouped by region and labelled with the UTC offset it is actually on — \"New York (UTC-04:00)\", \"Kolkata (UTC+05:30)\", \"Chatham (UTC+12:45)\". Reach for it wherever an app has to store which zone a time is meant in: the \"Your time zone\" row in profile, account or notification settings; a workspace or organisation default for a distributed team; scheduling and booking flows where the two parties are in different places; meeting, event and webinar creation; availability and working-hours editors; quiet hours and do-not-disturb windows; shift rosters and on-call rotations; the zone a cron job, scheduled report, digest email or CI job is read in; billing and invoice cycle boundaries; the \"display times in\" control on a dashboard, log viewer or analytics report; and any form that already collects a date and needs to know which midnight it meant. Common asks it answers: \"timezone picker\", \"timezone select\", \"time zone dropdown\", \"timezone selector react\", \"IANA timezone select\", \"select timezone component\", \"list of timezones react\", \"timezone select with UTC offset\", \"shadcn timezone picker\", \"shadcn time zone select\", \"react-timezone-select alternative\", \"timezone combobox\", \"choose timezone for scheduling\", \"user timezone setting component\". Official shadcn/ui has nothing for this and no combination of its parts gets there: select, native-select and combobox are empty controls that know no zones, and calendar and date-picker choose a day and never say which zone that day is counted in. The component here is the data and the labelling, not the control. The zone list comes from `Intl.supportedValuesOf(\"timeZone\")`, so it is the runtime's own tzdata — 418 zones on current browsers — and it ages with the browser instead of with a package you have to remember to bump. UTC is added explicitly, because that call omits it on several runtimes and it is the one zone a scheduling or logging UI is most likely to want. Offsets are read through `Intl` at a reference date rather than computed by subtracting two Dates, which is what keeps the zones that are not on a whole hour honest: India at +05:30, Chatham at +12:45, Marquesas at -09:30. And because an offset is a property of the date and not of the zone — Berlin is +01:00 in January and +02:00 in July — `referenceDate` moves the whole list to the instant being scheduled, so a picker for a meeting in three months does not label its options with today's daylight saving. It renders a native `<select>`, so keyboard support, the mobile wheel and form submission come from the platform rather than from a listbox reimplementation, which for a list this long is the difference between usable and not. That choice decides the labels too: a native select's only search is type-ahead, and labelling the options \"(UTC-04:00) New York\" the way most pickers do points all 418 entries at \"(\" and throws the feature away — so the city comes first, the offset trails in parentheses, and each region group is sorted alphabetically, in the same order type-ahead walks. Three failure modes it settles that only show up in production. The option list is built after mount, never during the server render, because the zone list, the tzdata behind the offsets and \"now\" are all properties of the machine — rendering them on both sides is a hydration mismatch on a page that was otherwise deterministic; before mount the field renders the current value under its raw id, so it is still present and submittable. The select is controlled internally even when the caller leaves it uncontrolled, because replacing the children of an uncontrolled select drops the DOM's selection and the field would silently reset on hydration. And a value the list does not contain is added back as its own option — the runtime offers canonical ids only, so a legacy form saved years ago is absent (current runtimes still answer to \"US/Pacific\" but do not list it), as is any zone picked before a narrowed list was narrowed instead of letting the select fall to its first entry and read as though the user had picked Abidjan. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), always emitting the IANA id and never a display label; `placeholder` adds an empty first option that `required` still rejects; `timeZones` narrows the list to the places a product actually operates in; and `getLocalTimeZone()` is exported for seeding the field with the visitor's own zone from an effect rather than from a render the server also runs. Labelled for assistive technology either way: it falls back to an accessible name only when no `aria-label`, `aria-labelledby` or `id` says one already exists, so a visible `<Label htmlFor>` is never overridden. Styled entirely with shadcn tokens (input, ring, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no timezone package, no icon package, one file.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/timezone-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface TimeZoneSelectProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"select\">,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"children\"\n  > {\n  /** Controlled IANA time zone id, e.g. \"Europe/Berlin\". Pair with `onValueChange`. */\n  value?: string\n  /** Starting zone for an uncontrolled select. Ignored once `value` is passed. */\n  defaultValue?: string\n  /** Called with the chosen IANA id. Never called with a display label. */\n  onValueChange?: (timeZone: string) => void\n  /**\n   * Shown as an empty first option, e.g. \"Select a time zone\". Omit it and the select starts\n   * on whatever `value`/`defaultValue` says. Its option carries the empty string, so `required`\n   * still fails an untouched field.\n   */\n  placeholder?: string\n  /**\n   * The instant the offsets are read at (default: when the component mounts). Offsets are a\n   * function of the date, not a property of the zone — Europe/Berlin is +01:00 in January and\n   * +02:00 in July — so a picker for a meeting in three months should pass that meeting's date\n   * rather than show today's offsets against it.\n   */\n  referenceDate?: Date\n  /**\n   * The zones to offer, in place of every zone the runtime knows. Pass this to narrow the list\n   * to the places a product actually operates in, or to supply one on a runtime without\n   * `Intl.supportedValuesOf`.\n   */\n  timeZones?: readonly string[]\n}\n\n/** Just enough of the ES2022 signature to feature-detect without widening the lib target. */\ninterface IntlWithSupportedValues {\n  supportedValuesOf?: (key: \"timeZone\") => string[]\n}\n\n/** \"GMT+05:30\", \"GMT-04:00\", or a bare \"GMT\" for zones sitting exactly on the meridian. */\nconst OFFSET_PATTERN = /^GMT(?:([+-])(\\d{2}):(\\d{2}))?$/\n\n/**\n * The visitor's own zone, or \"UTC\" where the runtime will not say.\n *\n * Exported because seeding the field with it is the common case and it is not the component's\n * job to guess: pass it to `defaultValue` from a client effect or from state, **not** from a\n * render that also runs on the server — the server's zone is the machine's, and rendering the\n * two against each other is the classic hydration mismatch.\n */\nexport function getLocalTimeZone(): string {\n  try {\n    return Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\"\n  } catch {\n    return \"UTC\"\n  }\n}\n\n/**\n * Every zone the runtime knows, plus UTC.\n *\n * `Intl.supportedValuesOf(\"timeZone\")` returns 418 canonical ids on current runtimes and, on\n * several of them, **UTC is not one of them** — every entry is `Region/City`. It is the one zone\n * a scheduling or logging UI is most likely to want, so it is added rather than left to chance,\n * and de-duplicated in case a runtime does include it.\n */\nfunction listTimeZones(): string[] {\n  const supportedValuesOf = (Intl as IntlWithSupportedValues).supportedValuesOf\n  let zones: string[] = []\n  if (typeof supportedValuesOf === \"function\") {\n    try {\n      zones = supportedValuesOf.call(Intl, \"timeZone\")\n    } catch {\n      zones = []\n    }\n  }\n  // Old runtimes (pre-2022) reach here with nothing. Degrading to the visitor's own zone keeps\n  // the field truthful and submittable instead of empty; a caller who has to serve those\n  // browsers passes `timeZones` and gets the full experience back.\n  if (zones.length === 0) zones = [getLocalTimeZone()]\n  return zones.includes(\"UTC\") ? zones : [\"UTC\", ...zones]\n}\n\n/**\n * Minutes east of UTC at `date`, or null for a zone this runtime cannot format.\n *\n * Read through `longOffset` rather than computed from two Date objects: the arithmetic version\n * has to round, which quietly loses the zones that are not on a whole hour — India at +05:30,\n * Chatham at +12:45, Marquesas at -09:30.\n */\nfunction offsetMinutesAt(timeZone: string, date: Date): number | null {\n  let offset: string | undefined\n  try {\n    offset = new Intl.DateTimeFormat(\"en-US\", { timeZone, timeZoneName: \"longOffset\" })\n      .formatToParts(date)\n      .find((part) => part.type === \"timeZoneName\")?.value\n  } catch {\n    return null\n  }\n  if (!offset) return null\n  const parsed = OFFSET_PATTERN.exec(offset)\n  if (!parsed) return null\n  if (!parsed[1]) return 0\n  const minutes = Number(parsed[2]) * 60 + Number(parsed[3])\n  return parsed[1] === \"-\" ? -minutes : minutes\n}\n\nfunction formatOffset(minutes: number): string {\n  const sign = minutes < 0 ? \"-\" : \"+\"\n  const absolute = Math.abs(minutes)\n  const hours = String(Math.floor(absolute / 60)).padStart(2, \"0\")\n  const rest = String(absolute % 60).padStart(2, \"0\")\n  return `UTC${sign}${hours}:${rest}`\n}\n\n/**\n * The part of the id worth reading, with the region stripped and underscores opened up:\n * \"America/New_York\" reads \"New York\", \"America/Argentina/Salta\" reads \"Argentina – Salta\".\n *\n * Deliberately city-first, with the offset appended by the caller rather than prefixed. A native\n * select has type-ahead — press \"n\" and the browser jumps to the first option starting with \"n\" —\n * and that is the only search a native control gets. Labelling these \"(UTC-04:00) New York\", the\n * way most pickers do, points every one of the 418 entries at \"(\" and throws the feature away.\n */\nfunction zoneLabel(timeZone: string): string {\n  const parts = timeZone.split(\"/\")\n  return (parts.length > 1 ? parts.slice(1) : parts).join(\" – \").replace(/_/g, \" \")\n}\n\ninterface ZoneOption {\n  zone: string\n  /** What the option renders: the city, then the offset in parentheses. */\n  text: string\n  /** Sorted on, so a group reads alphabetically the way type-ahead walks it. */\n  label: string\n}\n\ninterface ZoneGroup {\n  region: string\n  options: ZoneOption[]\n}\n\nfunction buildOptions(zones: readonly string[], at: Date) {\n  const loose: ZoneOption[] = []\n  const groups = new Map<string, ZoneOption[]>()\n  // The zones that made it into an option, which is not the same as the zones that came in —\n  // anything unformattable is dropped below, and the orphan test has to see it as absent.\n  const offered = new Set<string>()\n\n  for (const zone of zones) {\n    const offset = offsetMinutesAt(zone, at)\n    // A zone the runtime cannot format is one it cannot resolve either, so offering it would\n    // hand back an id that throws downstream. Dropping it is the honest outcome.\n    if (offset === null) continue\n    const label = zoneLabel(zone)\n    const option: ZoneOption = { zone, label, text: `${label} (${formatOffset(offset)})` }\n    offered.add(zone)\n    const slash = zone.indexOf(\"/\")\n    if (slash === -1) {\n      loose.push(option)\n      continue\n    }\n    const region = zone.slice(0, slash).replace(/_/g, \" \")\n    const bucket = groups.get(region)\n    if (bucket) bucket.push(option)\n    else groups.set(region, [option])\n  }\n\n  const byLabel = (a: ZoneOption, b: ZoneOption) => a.label.localeCompare(b.label)\n  loose.sort(byLabel)\n  const grouped: ZoneGroup[] = [...groups.entries()]\n    .map(([region, options]) => ({ region, options: options.sort(byLabel) }))\n    .sort((a, b) => a.region.localeCompare(b.region))\n\n  // `offered` is carried alongside the options so the \"is the current value in here\" test below\n  // is a lookup rather than a walk of all 418 on every render of every parent.\n  return { loose, grouped, offered }\n}\n\n/**\n * A time zone picker: every IANA zone the browser knows, grouped by region and labelled with the\n * offset it is actually on at a given date.\n *\n * ```tsx\n * const [zone, setZone] = React.useState(\"\")\n *\n * React.useEffect(() => setZone(getLocalTimeZone()), [])\n *\n * return (\n *   <>\n *     <Label htmlFor=\"tz\">Time zone</Label>\n *     <TimeZoneSelect id=\"tz\" value={zone} onValueChange={setZone} placeholder=\"Select a time zone\" />\n *   </>\n * )\n * ```\n *\n * It is a native `<select>`, so keyboard support, type-ahead, the mobile wheel and form\n * submission come from the platform rather than from a listbox reimplementation — which for a\n * list this long is the difference between usable and not.\n *\n * Sizing goes on a parent: `className` lands on the select, which fills its wrapper, and the\n * chevron is positioned against that wrapper.\n */\nexport const TimeZoneSelect = React.forwardRef<HTMLSelectElement, TimeZoneSelectProps>(\n  function TimeZoneSelect(\n    {\n      className,\n      value: valueProp,\n      defaultValue,\n      onValueChange,\n      placeholder,\n      referenceDate,\n      timeZones,\n      ...props\n    },\n    ref\n  ) {\n    /**\n     * The option list is built after mount, never during the server render, and this is the\n     * whole reason the flag exists.\n     *\n     * Three things here are properties of the machine rather than of the props: the zone list\n     * (the server's ICU build and the browser's can disagree), the offsets (they come from a\n     * tzdata that either side may have patched more recently), and \"now\" if no `referenceDate`\n     * is given. Rendering any of them on both sides invites a hydration mismatch on a page that\n     * was otherwise deterministic. Before mount the select therefore renders only what the props\n     * already say — the current value, labelled with its raw id — so the field is present,\n     * correct and submittable, and the labelled list swaps in on hydration.\n     */\n    const [mounted, setMounted] = React.useState(false)\n    React.useEffect(() => {\n      setMounted(true)\n    }, [])\n\n    const isControlled = valueProp !== undefined\n    const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? \"\")\n    const value = isControlled ? valueProp : uncontrolled\n\n    // A Date is a new object every render, so the instant is what the memo can depend on.\n    const referenceTime = referenceDate ? referenceDate.getTime() : null\n    const options = React.useMemo(() => {\n      if (!mounted) return null\n      const at = referenceTime === null ? new Date() : new Date(referenceTime)\n      return buildOptions(timeZones ?? listTimeZones(), at)\n    }, [mounted, referenceTime, timeZones])\n\n    /**\n     * A value the list does not contain is added back as its own option. Two ways a column of\n     * stored zones gets there: the runtime lists canonical ids, so a legacy form saved years ago\n     * is missing (current runtimes still answer to \"US/Pacific\" but do not offer it), and a\n     * narrowed `timeZones` will not contain the zone a user picked before it was narrowed.\n     * Without this the select falls to its first option and the mismatch reads, silently and\n     * on save, as the user having chosen Abidjan.\n     */\n    const orphan = options && value && !options.offered.has(value) ? value : null\n\n    /**\n     * Only when nothing else names the control. An `aria-label` here would override a visible\n     * `<Label htmlFor>` and announce the generic word instead of the caller's own, and an `id`\n     * is what that pairing needs, so its presence is taken as the label existing.\n     */\n    const needsFallbackLabel =\n      props[\"aria-label\"] === undefined &&\n      props[\"aria-labelledby\"] === undefined &&\n      props.id === undefined\n\n    return (\n      <div className=\"relative\">\n        <select\n          ref={ref}\n          // Controlled even when the caller is not, because the options arrive in a second pass:\n          // replacing the children of an uncontrolled select drops the DOM's selection, and the\n          // field would reset itself on hydration.\n          value={value}\n          onChange={(event) => {\n            const next = event.currentTarget.value\n            if (!isControlled) setUncontrolled(next)\n            onValueChange?.(next)\n          }}\n          aria-label={needsFallbackLabel ? \"Time zone\" : undefined}\n          className={cn(\n            \"flex h-9 w-full appearance-none items-center rounded-md border border-input bg-transparent py-1 pl-3 pr-8 text-sm shadow-sm transition-colors\",\n            \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n            \"disabled:cursor-not-allowed disabled:opacity-50\",\n            // The closed select shows the placeholder in muted text like an empty input, while\n            // the options themselves stay at full contrast on the open list.\n            value === \"\" && \"text-muted-foreground\",\n            \"[&>optgroup]:text-foreground [&>option]:text-foreground\",\n            className\n          )}\n          {...props}\n        >\n          {placeholder !== undefined ? <option value=\"\">{placeholder}</option> : null}\n          {options === null ? (\n            value ? (\n              <option value={value}>{value}</option>\n            ) : null\n          ) : (\n            <>\n              {orphan ? <option value={orphan}>{orphan}</option> : null}\n              {options.loose.map((option) => (\n                <option key={option.zone} value={option.zone}>\n                  {option.text}\n                </option>\n              ))}\n              {options.grouped.map((group) => (\n                <optgroup key={group.region} label={group.region}>\n                  {group.options.map((option) => (\n                    <option key={option.zone} value={option.zone}>\n                      {option.text}\n                    </option>\n                  ))}\n                </optgroup>\n              ))}\n            </>\n          )}\n        </select>\n\n        {/* Inline, so one glyph costs no icon dependency. */}\n        <svg\n          className=\"pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground\"\n          width=\"16\"\n          height=\"16\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"m6 9 6 6 6-6\" />\n        </svg>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}