All blocks
Timezone Picker
Select-style timezone picker with a search input inside the panel — IANA zones and live offsets from Intl, filtered accessibly.
Built withCombobox
Timezone
Install
1npx stylexblocks add timezone-pickerSource
components/blocks/base-ui/timezone-picker.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146import * as stylex from "@stylexjs/stylex";import { useMemo } from "react";import { Combobox, type ComboboxVariantProps } from "#components/ui/base-ui/combobox.tsx";import { semanticColors } from "@stylexblocks/ui/semantic-tokens/colors.stylex.ts";import type { StyleOverrideProps } from "#lib/recipe-props.ts";
export interface Timezone { /** IANA zone id, e.g. "Europe/Amsterdam" — what your backend receives. */ value: string; /** City part of the zone id, e.g. "Amsterdam". */ label: string; /** Current UTC offset, e.g. "GMT+2" (DST-aware at render time). */ offset: string; /** Offset in minutes, for sorting. */ minutes: number;}
/** * Every IANA timezone the runtime knows, with current offsets — no shipped * data to go stale. Sorted by offset, then name. */export function getTimezones(): Timezone[] { const zones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : ["UTC"]; const now = new Date(); return zones .map((zone) => { const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "shortOffset", }).formatToParts(now); const offset = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; const m = offset.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); const minutes = m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) : 0; const label = zone.split("/").pop()!.replaceAll("_", " "); return { value: zone, label, offset, minutes }; }) .sort((a, b) => a.minutes - b.minutes || a.label.localeCompare(b.label));}
const styles = stylex.create({ offset: { color: semanticColors.fgSubtle, fontSize: "0.8125em", marginInlineStart: "auto", paddingInlineStart: "0.75rem", },});
export interface TimezonePickerProps extends ComboboxVariantProps, StyleOverrideProps { label?: string; /** Trigger text while nothing is selected. */ placeholder?: string; searchPlaceholder?: string; /** IANA zone id of the initial selection. */ defaultValue?: string; /** IANA zone id of the controlled selection. */ value?: string; onValueChange?: (zone: string) => void; /** Posts the selected zone id with native form submits via a hidden input. */ name?: string; /** Override the offered zones, e.g. only your supported regions. */ items?: Timezone[]; disabled?: boolean;}
/** * TimezonePicker — select-style trigger that opens a panel with a search * input above the list (the "select with filter" pattern). Base UI's * combobox supports this natively: the trigger opens the popup, focus moves * to the input inside it, and the input carries the ARIA combobox wiring * (aria-expanded, aria-controls, aria-activedescendant), so filtering stays * screen-reader-accessible. Zones and offsets come from Intl at render time. */export function TimezonePicker({ label = "Timezone", placeholder = "Select timezone", searchPlaceholder = "Search timezones…", defaultValue, value, onValueChange, name, items, disabled, variant = "outline", size = "md", palette, style,}: TimezonePickerProps) { const zones = useMemo(() => items ?? getTimezones(), [items]);
const byId = (zone?: string) => zones.find((z) => z.value === zone) ?? null;
return ( <Combobox.Root items={zones} itemToStringLabel={(zone: Timezone | null) => (zone ? `${zone.label} (${zone.offset})` : "")} itemToStringValue={(zone: Timezone | null) => zone?.value ?? ""} // match the city name/offset, or anywhere in the IANA id ("europe/ams") filter={(zone: Timezone, query: string) => { const q = query.toLowerCase(); return ( zone.label.toLowerCase().includes(q) || zone.value.toLowerCase().includes(q) || zone.offset.toLowerCase().includes(q) ); }} defaultValue={byId(defaultValue) ?? undefined} value={value !== undefined ? (byId(value) ?? undefined) : undefined} onValueChange={(zone: Timezone | null) => onValueChange?.(zone?.value ?? "")} name={name} disabled={disabled} variant={variant} size={size} palette={palette} style={style} > <Combobox.Label>{label}</Combobox.Label> <Combobox.Control> <Combobox.FieldTrigger> <Combobox.FieldValue placeholder={placeholder}> {(zone: Timezone | null) => (zone ? `${zone.label} (${zone.offset})` : null)} </Combobox.FieldValue> </Combobox.FieldTrigger> </Combobox.Control> <Combobox.Positioner> <Combobox.Panel> <Combobox.Search placeholder={searchPlaceholder} /> <Combobox.PanelList> <Combobox.Empty>No timezones found</Combobox.Empty> <Combobox.List> {(zone: Timezone) => ( <Combobox.Item key={zone.value} value={zone}> <Combobox.ItemText>{zone.label}</Combobox.ItemText> <span {...stylex.props(styles.offset)}>{zone.offset}</span> <Combobox.ItemIndicator /> </Combobox.Item> )} </Combobox.List> </Combobox.PanelList> </Combobox.Panel> </Combobox.Positioner> </Combobox.Root> );}