Every IANA zone with live local time, searchable by city, name or offset.
01Preview
Time and region
Used for your working hours, reminders and when digests arrive.
Time zone
Team default
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react @number-flow/react03Usage
import { TimezoneSelect } from "@/components/ui/timezone-select";
// Starts on the viewer's zone.
<TimezoneSelect label="Time zone" onValueChange={(tz) => save({ timeZone: tz })} />
<TimezoneSelect value={team.timeZone} onValueChange={setTeamZone} size="sm" showDetected={false} />04Source
"use client";
import { Combobox } from "@base-ui/react/combobox";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { Check, ChevronsUpDown, Globe, Search } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
/* ------------------------------------------------------------------ */
/* Zones, from Intl alone. */
/* ------------------------------------------------------------------ */
export type TimeZoneInfo = {
/** The IANA id, exactly as the engine reports it. */
id: string;
/** "Kolkata", "New York", "UTC". */
city: string;
/** "Argentina" for America/Argentina/Salta; empty when there's no middle part. */
area: string;
/** "India Standard Time", "Pacific Time". */
name: string;
region: string;
/** Minutes ahead of UTC right now. */
offset: number;
/** Everything a search can match, folded to lowercase ASCII. */
haystack: string;
};
const REGIONS: [string, string][] = [
["America", "Americas"],
["Europe", "Europe"],
["Africa", "Africa"],
["Asia", "Asia"],
["Australia", "Australia"],
["Pacific", "Pacific"],
["Atlantic", "Atlantic"],
["Indian", "Indian Ocean"],
["Antarctica", "Antarctica"],
["Arctic", "Arctic"],
["Other", "Other"],
];
// Old spellings some engines still hand back, shown by their current names and found by both.
const RENAMED: Record<string, string> = { Calcutta: "Kolkata", Saigon: "Ho Chi Minh City", Katmandu: "Kathmandu", Kiev: "Kyiv", Rangoon: "Yangon", Godthab: "Nuuk", Ashkhabad: "Ashgabat", Chongqing: "Chongqing", Faeroe: "Faroe" };
// What people type that no zone is called.
const ALIASES: Record<string, string> = {
"America/Los_Angeles": "pst pdt pt pacific san francisco seattle california",
"America/New_York": "est edt et eastern boston washington miami toronto",
"America/Chicago": "cst cdt ct central dallas houston",
"America/Denver": "mst mdt mt mountain",
"America/Phoenix": "arizona",
"Europe/London": "gmt bst uk britain england",
"Europe/Paris": "cet cest france",
"Europe/Berlin": "cet cest germany",
"Asia/Kolkata": "ist india mumbai delhi bangalore bengaluru",
"Asia/Calcutta": "ist india mumbai delhi bangalore bengaluru",
"Asia/Tokyo": "jst japan",
"Asia/Shanghai": "china beijing",
"Asia/Singapore": "sgt",
"Asia/Hong_Kong": "hkt",
"Australia/Sydney": "aest aedt melbourne canberra",
"Asia/Dubai": "gst uae",
"America/Sao_Paulo": "brt brazil",
UTC: "utc gmt zulu coordinated universal",
};
const FALLBACK = "America/Los_Angeles America/Denver America/Chicago America/New_York America/Sao_Paulo Europe/London Europe/Paris Europe/Berlin Europe/Kyiv Africa/Lagos Africa/Johannesburg Asia/Dubai Asia/Kolkata Asia/Singapore Asia/Shanghai Asia/Tokyo Australia/Sydney Pacific/Auckland".split(" ");
export const fold = (s: string) =>
s
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toLowerCase();
const nameFmt = new Map<string, Intl.DateTimeFormat>();
function zonePart(at: Date, timeZone: string, style: "longGeneric" | "shortOffset", locale = "en-US") {
const key = `${locale}|${timeZone}|${style}`;
let f = nameFmt.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: style });
nameFmt.set(key, f);
}
return f.formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? "";
}
/** Minutes the zone is ahead of UTC at that instant. */
export function zoneOffset(timeZone: string, at = new Date()) {
const label = zonePart(at, timeZone, "shortOffset");
const m = /GMT([+-−])(\d{1,2})(?::(\d{2}))?/.exec(label);
if (!m) return 0;
return (m[1] === "+" ? 1 : -1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
}
/** "GMT+5:30", "GMT−3", "GMT". A real minus sign, so columns of offsets read cleanly. */
export function formatOffset(minutes: number) {
if (minutes === 0) return "GMT";
const a = Math.abs(minutes);
return `GMT${minutes > 0 ? "+" : "−"}${Math.floor(a / 60)}${a % 60 ? `:${String(a % 60).padStart(2, "0")}` : ""}`;
}
export function zoneCity(id: string) {
if (id === "UTC" || id === "Etc/UTC") return "UTC";
const last = (id.split("/").pop() ?? id).replace(/_/g, " ");
return RENAMED[last.replace(/ /g, "")] ?? last;
}
let cache: { at: number; zones: TimeZoneInfo[] } | null = null;
/** Every zone the engine knows, with its current offset and generic name. Rebuilt at most once a minute. */
export function getTimeZones(now = new Date()): TimeZoneInfo[] {
const minute = Math.floor(now.getTime() / 60000);
if (cache && cache.at === minute) return cache.zones;
let ids: string[];
try {
ids = (Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf("timeZone");
} catch {
ids = FALLBACK;
}
if (!ids.includes("UTC")) ids = [...ids, "UTC"];
const zones = ids.map((id) => {
const parts = id.split("/");
const region = parts.length > 1 && REGIONS.some(([k]) => k === parts[0]) ? parts[0] : "Other";
const city = zoneCity(id);
const area = parts.length > 2 ? parts.slice(1, -1).join(" / ").replace(/_/g, " ") : "";
const name = id === "UTC" ? "Coordinated Universal Time" : zonePart(now, id, "longGeneric");
const offset = zoneOffset(id, now);
const raw = (parts.pop() ?? "").replace(/_/g, " ");
return { id, city, area, name, region, offset, haystack: fold([city, raw, area, name, id, ALIASES[id] ?? ""].join(" ")) };
});
cache = { at: minute, zones };
return zones;
}
/** Reads "+5:30", "gmt-3", "UTC+9", "−4" as an offset in minutes. */
function parseOffset(q: string): number | null {
const m = /^(?:utc|gmt)?\s*([+\-−])\s*(\d{1,2})(?::?(\d{2}))?$/.exec(q.trim());
if (!m) return null;
return (m[1] === "+" ? 1 : -1) * (Number(m[2]) * 60 + Number(m[3] ?? 0));
}
function rank(zone: TimeZoneInfo, q: string) {
const city = fold(zone.city);
if (city === q) return 0;
if (city.startsWith(q)) return 1;
if (zone.haystack.split(/[\s/]+/).some((w) => w.startsWith(q))) return 2;
return 3;
}
export function filterTimeZones(zones: TimeZoneInfo[], query: string) {
const q = fold(query.trim());
if (!q) return zones;
const offset = parseOffset(q);
if (offset !== null) return zones.filter((z) => z.offset === offset);
const terms = q.split(/\s+/);
// Every term has to start a word, so "new y" finds New York and not Papua New Guinea.
return zones
.filter((z) => {
const words = z.haystack.split(/[\s/()-]+/);
return terms.every((t) => words.some((w) => w.startsWith(t)));
})
.map((z) => ({ z, r: rank(z, terms[0]) }))
.sort((a, b) => a.r - b.r)
.map(({ z }) => z);
}
/* ------------------------------------------------------------------ */
/* Browser-only clocks. */
/* ------------------------------------------------------------------ */
// Ticks on the minute boundary; stops while the tab is hidden and catches up when it returns.
function subscribeMinute(notify: () => void) {
let timer = 0;
const arm = () => {
window.clearTimeout(timer);
if (document.visibilityState === "hidden") return;
timer = window.setTimeout(() => (notify(), arm()), 60000 - (Date.now() % 60000) + 20);
};
const onVisible = () => (notify(), arm());
arm();
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}
/** The current minute, live. Null during server render. */
export function useMinute() {
return useSyncExternalStore(subscribeMinute, () => Math.floor(Date.now() / 60000), () => null);
}
const noop = () => () => {};
/** The viewer's zone. Null during server render. */
export function useDetectedTimeZone() {
return useSyncExternalStore(noop, () => Intl.DateTimeFormat().resolvedOptions().timeZone, () => null);
}
function useLocale(locale?: string) {
const browser = useSyncExternalStore(noop, () => navigator.language, () => "en-US");
return locale ?? browser;
}
function prefers12h(locale: string) {
try {
const cycle = new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions().hourCycle;
return cycle === "h11" || cycle === "h12";
} catch {
return false;
}
}
const timeCache = new Map<string, Intl.DateTimeFormat>();
function timeFormat(locale: string, hour12: boolean, timeZone: string) {
const key = `${locale}|${hour12}|${timeZone}`;
let f = timeCache.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", hour12, timeZone });
timeCache.set(key, f);
}
return f;
}
/** Wall-clock hour, minute and day in a zone. */
function clockIn(timeZone: string, at: Date) {
const shifted = new Date(at.getTime() + zoneOffset(timeZone, at) * 60000);
return { h: shifted.getUTCHours(), m: shifted.getUTCMinutes(), day: Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) / 864e5 };
}
/* ------------------------------------------------------------------ */
type Group = { value: string; label: string; items: TimeZoneInfo[] };
export type TimezoneSelectProps = {
/** The IANA id, when controlled. */
value?: string | null;
/** Defaults to the viewer's zone, once it's known. */
defaultValue?: string | null;
onValueChange?: (timeZone: string) => void;
/** Restrict the list, e.g. to the zones your product supports. */
timeZones?: string[];
locale?: string;
hour12?: boolean;
/** Pin the viewer's zone at the top of the list. */
showDetected?: boolean;
label?: React.ReactNode;
"aria-label"?: string;
size?: "sm" | "md";
disabled?: boolean;
name?: string;
container?: Combobox.Portal.Props["container"];
className?: string;
};
export function TimezoneSelect({
value: valueProp,
defaultValue,
onValueChange,
timeZones,
locale: localeProp,
hour12: hour12Prop,
showDetected = true,
label,
"aria-label": ariaLabel = "Time zone",
size = "md",
disabled,
name,
container,
className,
}: TimezoneSelectProps) {
const reduce = !!useReducedMotion();
const minute = useMinute();
const detected = useDetectedTimeZone();
const locale = useLocale(localeProp);
const hour12 = hour12Prop ?? prefers12h(locale);
const [inner, setInner] = useState<string | null | undefined>(defaultValue);
const current = valueProp !== undefined ? valueProp : (inner ?? detected);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const now = useMemo(() => (minute === null ? null : new Date(minute * 60000)), [minute]);
// The full list is only built on the client, and only rebuilt when the minute (and so maybe an offset) changes.
const zones = useMemo(() => {
if (!now) return [];
const all = getTimeZones(now);
const allowed = timeZones ? new Set(timeZones) : null;
return all.filter((z) => !allowed || allowed.has(z.id));
}, [now, timeZones]);
const selected = useMemo(() => zones.find((z) => z.id === current) ?? (current && now ? makeInfo(current, now) : null), [zones, current, now]);
const home = useMemo(() => (detected && now ? (zones.find((z) => z.id === detected) ?? makeInfo(detected, now)) : null), [zones, detected, now]);
const groups = useMemo<Group[]>(() => {
const matched = filterTimeZones(zones, query);
const searching = !!query.trim();
const out: Group[] = [];
if (showDetected && home && !searching) out.push({ value: "detected", label: "Your time zone", items: [home] });
for (const [key, title] of REGIONS) {
const items = matched.filter((z) => z.region === key);
// Browsing: west to east within a region. Searching: best match first.
if (!searching) items.sort((a, b) => a.offset - b.offset || a.city.localeCompare(b.city));
if (items.length) out.push({ value: key, label: title, items });
}
// One group of best matches reads better than scattered hits when searching.
if (searching) return out.length ? [{ value: "results", label: `${matched.length} ${matched.length === 1 ? "match" : "matches"}`, items: matched }] : [];
return out;
}, [zones, query, showDetected, home]);
const choose = (z: TimeZoneInfo | null) => {
if (!z) return;
if (valueProp === undefined) setInner(z.id);
onValueChange?.(z.id);
};
const timeIn = (id: string) => (now ? timeFormat(locale, hour12, id).format(now) : "");
const homeDay = detected && now ? clockIn(detected, now).day : null;
const dayShift = (id: string) => (now && homeDay !== null ? clockIn(id, now).day - homeDay : 0);
const { setList, y, height, opacity } = useGlide(reduce);
const clock = selected && now ? clockIn(selected.id, now) : null;
return (
<Combobox.Root<TimeZoneInfo>
items={groups}
filteredItems={groups}
value={selected}
onValueChange={choose}
inputValue={query}
onInputValueChange={setQuery}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
autoHighlight
isItemEqualToValue={(a, b) => a.id === b.id}
itemToStringLabel={(z) => z.city}
itemToStringValue={(z) => z.id}
disabled={disabled}
name={name}
>
<div className={cn("flex w-full min-w-0 max-w-[340px] flex-col gap-1.5", className)}>
{label && <Combobox.Label className="w-fit cursor-default text-[12.5px] font-medium text-fg-2 select-none">{label}</Combobox.Label>}
<Combobox.Trigger
aria-label={label ? undefined : ariaLabel}
data-size={size}
className={cn(
"group/trigger relative flex w-full min-w-0 items-center border border-line-2 bg-raised text-left text-fg shadow-[var(--shadow)] select-none",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.985] active:duration-75",
"data-popup-open:border-fg-4 data-popup-open:bg-hover data-disabled:pointer-events-none data-disabled:opacity-50",
size === "sm" ? "h-7 gap-1.5 rounded-md pr-1.5 pl-2 text-[12.5px]" : "h-8 gap-2 rounded-lg pr-2 pl-2.5 text-[13px]",
)}
>
<Globe size={size === "sm" ? 14 : 16} className="shrink-0 text-fg-3" />
<span className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)] overflow-hidden py-1">
<AnimatePresence initial={false}>
<motion.span
key={selected?.id ?? "none"}
className="col-start-1 row-start-1 flex min-w-0 items-baseline gap-1.5"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}
>
{selected ? (
<>
<span className="truncate">{selected.city}</span>
<span className="shrink-0 font-mono text-2xs text-fg-4">{formatOffset(selected.offset)}</span>
</>
) : (
<span className="my-0.5 h-3 w-28 rounded bg-hover" />
)}
</motion.span>
</AnimatePresence>
</span>
{/* The clock rolls rather than swaps: a new zone moves the hours, a new minute moves the minutes. */}
<span className="shrink-0 text-[12.5px] text-fg-3 tabular" aria-hidden>
{clock ? (
<NumberFlowGroup>
<span className="inline-flex items-baseline">
<NumberFlow value={hour12 ? clock.h % 12 || 12 : clock.h} format={{ minimumIntegerDigits: hour12 ? 1 : 2 }} />
<span>:</span>
<NumberFlow value={clock.m} format={{ minimumIntegerDigits: 2 }} />
{hour12 && <span className="ml-1">{clock.h < 12 ? "AM" : "PM"}</span>}
</span>
</NumberFlowGroup>
) : (
<span className="inline-block h-3 w-10 rounded bg-hover align-middle" />
)}
</span>
<ChevronsUpDown size={size === "sm" ? 14 : 16} className="shrink-0 text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2" />
</Combobox.Trigger>
</div>
<Combobox.Portal container={container}>
<Combobox.Positioner align="start" sideOffset={6} collisionPadding={8} className="z-(--z-popover) outline-none">
<Combobox.Popup
aria-label="Choose a time zone"
className={cn(
"flex w-[max(var(--anchor-width),22rem)] max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out",
"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0",
"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100",
)}
>
<div className="relative flex items-center border-b border-line">
<Search size={14} className="pointer-events-none absolute left-3 text-fg-3" />
<Combobox.Input
placeholder="City, zone or offset like +5:30"
aria-label="Search time zones"
className="h-10 w-full bg-transparent pr-3 pl-8 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]"
/>
</div>
<Combobox.Empty className="empty:hidden">
<div className="flex flex-col gap-0.5 px-3 py-6 text-center">
<p className="truncate text-[13px] text-fg-2">No time zones match “{query.trim()}”</p>
<p className="text-[12px] text-fg-3">Try a city, a country’s zone name or an offset.</p>
</div>
</Combobox.Empty>
<Combobox.List
ref={setList}
className="relative max-h-[min(calc(var(--available-height)-2.5rem),20rem)] scroll-pt-8 scroll-pb-1 overflow-y-auto overscroll-contain px-1 pb-1 outline-none empty:hidden"
>
<motion.div aria-hidden style={{ y, height, opacity }} className="pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line" />
{groups.map((group) => (
<Combobox.Group key={group.value} items={group.items} className="block">
<Combobox.GroupLabel className="sticky top-0 z-[2] flex h-8 items-end bg-raised px-2 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase select-none">
{group.label}
</Combobox.GroupLabel>
<Combobox.Collection>
{(z: TimeZoneInfo) => {
const shift = dayShift(z.id);
return (
<Combobox.Item
key={`${group.value}:${z.id}`}
value={z}
className="group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg pr-2 pl-2 text-[13px] outline-none select-none pointer-coarse:h-10"
>
<span className="grid size-4 shrink-0 place-items-center">
<Combobox.ItemIndicator>
<Check size={14} />
</Combobox.ItemIndicator>
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shrink-0 truncate text-fg">
<Highlight text={z.city} query={query} />
</span>
<span className="min-w-0 truncate text-[12px] text-fg-3">{z.area ? `${z.area} · ${z.name}` : z.name}</span>
</span>
<span className="w-[4.25rem] shrink-0 text-right font-mono text-2xs text-fg-4">{formatOffset(z.offset)}</span>
<span className="flex w-[4.75rem] shrink-0 items-baseline justify-end gap-1 text-[12.5px] text-fg-2 tabular">
{timeIn(z.id)}
{shift !== 0 && (
<sup className="font-mono text-[9.5px] text-fg-3" aria-label={shift > 0 ? "next day" : "previous day"}>
{shift > 0 ? "+1" : "−1"}
</sup>
)}
</span>
</Combobox.Item>
);
}}
</Combobox.Collection>
</Combobox.Group>
))}
</Combobox.List>
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
);
}
function makeInfo(id: string, now: Date): TimeZoneInfo {
const parts = id.split("/");
return { id, city: zoneCity(id), area: "", name: zonePart(now, id, "longGeneric"), region: parts.length > 1 ? parts[0] : "Other", offset: zoneOffset(id, now), haystack: fold(id) };
}
/** The matched run of a city in the primary color, the rest a step quieter. */
function Highlight({ text, query }: { text: string; query: string }) {
const q = fold(query.trim());
if (!q || parseOffset(q) !== null) return <>{text}</>;
// The whole query when the city holds it ("new y" in New York), else its first word.
const run = fold(text).includes(q) ? q : q.split(/\s+/)[0];
const at = fold(text).indexOf(run);
if (at < 0) return <>{text}</>;
const end = at + run.length;
return (
<>
<span className="text-fg-2">{text.slice(0, at)}</span>
<mark className="bg-transparent font-medium text-fg">{text.slice(at, end)}</mark>
<span className="text-fg-2">{text.slice(end)}</span>
</>
);
}
// One highlight for the list: glides after the pointer, jumps for arrow keys and typing.
function useGlide(reduce: boolean) {
const [list, setList] = useState<HTMLDivElement | null>(null);
const y = useMotionValue(0);
const height = useMotionValue(32);
const opacity = useMotionValue(0);
useEffect(() => {
if (!list) return;
let keyboard = false;
let shown = false;
let running: AnimationPlaybackControls[] = [];
const stop = () => {
running.forEach((c) => c.stop());
running = [];
};
const place = () => {
const el = list.querySelector<HTMLElement>("[data-highlighted]");
stop();
if (!el) {
shown = false;
running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));
return;
}
let top = 0;
for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;
if (!shown || keyboard || reduce) {
y.jump(top);
height.jump(el.offsetHeight);
} else {
running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));
}
opacity.jump(1);
shown = true;
};
const onKey = () => (keyboard = true);
const onPointer = () => (keyboard = false);
const observer = new MutationObserver(place);
observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-highlighted"] });
document.addEventListener("keydown", onKey, true);
list.addEventListener("pointermove", onPointer);
place();
return () => {
observer.disconnect();
document.removeEventListener("keydown", onKey, true);
list.removeEventListener("pointermove", onPointer);
stop();
};
}, [list, reduce, y, height, opacity]);
return { setList, y, height, opacity };
}05Props
TimezoneSelect
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | null | — | The IANA id, when controlled. |
| defaultValue | string | null | the viewer's zone | The initial zone, when uncontrolled. |
| onValueChange | (timeZone: string) => void | — | Called with the IANA id when a zone is chosen. |
| timeZones | string[] | — | Restrict the list to these ids. |
| showDetected | boolean | true | Pin the viewer's zone at the top under Your time zone. |
| label | ReactNode | — | Visible label above the trigger. |
| aria-label | string | "Time zone" | Accessible name when there's no visible label. |
| locale | string | — | BCP 47 tag for the clock. Defaults to the browser's. |
| hour12 | boolean | — | 12-hour clock. Defaults to the locale's habit. |
| size | "sm" | "md" | "md" | 28px or 32px tall. |
| disabled | boolean | false | Ignores input. |
| name | string | — | Submits the IANA id with a form. |
| container | HTMLElement | RefObject<HTMLElement> | — | Portal target for the list. |
Helpers
| Prop | Type | Default | Description |
|---|---|---|---|
| getTimeZones | (now?: Date) => TimeZoneInfo[] | — | Every zone with city, generic name, region and current offset. Cached per minute. |
| filterTimeZones | (zones, query) => TimeZoneInfo[] | — | The search the list uses: word starts, aliases like PST or IST, and offsets like +5:30. |
| zoneOffset | (timeZone: string, at?: Date) => number | — | Minutes ahead of UTC at that instant. |
| formatOffset | (minutes: number) => string | — | "GMT+5:30", "GMT−3", "GMT". |
| useDetectedTimeZone / useMinute | () => string | null / () => number | null | — | The viewer's zone, and a minute ticker that pauses in hidden tabs. Null on the server. |
06Notes
Behavior
- Zones come from Intl.supportedValuesOf with UTC added; engines that lack it fall back to 18 common zones. Old spellings (Calcutta, Kiev, Saigon) show their current names and are found by both.
- Browsing groups by region, west to east. Searching collapses to one ranked list: exact city, then city prefix, then any word, with aliases (PST, IST, CET) and offsets (+5:30, UTC−3).
- Times tick on the minute boundary and stop while the tab is hidden. A +1 or −1 marks zones already on another day from the viewer.
- The list and clock only render in the browser; the trigger reserves its space with placeholders so hydration never shifts it.
Motion
- The trigger's clock is two rolling numbers: changing zone rolls the hours, a new minute rolls the minutes. The city swaps with an 8px rise and 2px blur, 220ms on the expo ease-out.
- The list grows from the trigger in 160ms and the highlight glides after the pointer on a spring, jumping instantly for arrow keys and typing. Reduced motion keeps fades and drops travel, blur and rolls.
Accessibility
- A combobox with the search input inside the popup: arrows move, Enter chooses, Escape closes and returns focus to the trigger.
- Group labels are real group headings; the day marker has a spoken label (next day, previous day). The trigger's clock is hidden from screen readers so it doesn't chatter every minute.