A searchable timezone picker in a popover: zones grouped by region and sorted by offset, tabular UTC offsets, keyboard navigation, and a selected + UTC preview line.
npx shadcn@latest add @paragon/timezone-selectAlso installs: popover
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Globe2, Search } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";
export interface TimezoneOption {
/** IANA zone id, e.g. "America/New_York". */
id: string;
city: string;
region: string;
}
/** Curated common zones, grouped by continent. Override via `zones`. */
export const DEFAULT_TIMEZONES: TimezoneOption[] = [
{ id: "Pacific/Honolulu", city: "Honolulu", region: "Australia & Pacific" },
{ id: "America/Anchorage", city: "Anchorage", region: "Americas" },
{ id: "America/Los_Angeles", city: "Los Angeles", region: "Americas" },
{ id: "America/Vancouver", city: "Vancouver", region: "Americas" },
{ id: "America/Denver", city: "Denver", region: "Americas" },
{ id: "America/Phoenix", city: "Phoenix", region: "Americas" },
{ id: "America/Chicago", city: "Chicago", region: "Americas" },
{ id: "America/Mexico_City", city: "Mexico City", region: "Americas" },
{ id: "America/New_York", city: "New York", region: "Americas" },
{ id: "America/Toronto", city: "Toronto", region: "Americas" },
{ id: "America/Bogota", city: "Bogotá", region: "Americas" },
{ id: "America/Santiago", city: "Santiago", region: "Americas" },
{ id: "America/Sao_Paulo", city: "São Paulo", region: "Americas" },
{ id: "America/Argentina/Buenos_Aires", city: "Buenos Aires", region: "Americas" },
{ id: "UTC", city: "UTC", region: "UTC" },
{ id: "Europe/London", city: "London", region: "Europe" },
{ id: "Europe/Dublin", city: "Dublin", region: "Europe" },
{ id: "Europe/Lisbon", city: "Lisbon", region: "Europe" },
{ id: "Europe/Madrid", city: "Madrid", region: "Europe" },
{ id: "Europe/Paris", city: "Paris", region: "Europe" },
{ id: "Europe/Amsterdam", city: "Amsterdam", region: "Europe" },
{ id: "Europe/Berlin", city: "Berlin", region: "Europe" },
{ id: "Europe/Zurich", city: "Zurich", region: "Europe" },
{ id: "Europe/Stockholm", city: "Stockholm", region: "Europe" },
{ id: "Europe/Warsaw", city: "Warsaw", region: "Europe" },
{ id: "Europe/Athens", city: "Athens", region: "Europe" },
{ id: "Europe/Istanbul", city: "Istanbul", region: "Europe" },
{ id: "Europe/Kyiv", city: "Kyiv", region: "Europe" },
{ id: "Europe/Moscow", city: "Moscow", region: "Europe" },
{ id: "Africa/Casablanca", city: "Casablanca", region: "Africa" },
{ id: "Africa/Lagos", city: "Lagos", region: "Africa" },
{ id: "Africa/Cairo", city: "Cairo", region: "Africa" },
{ id: "Africa/Nairobi", city: "Nairobi", region: "Africa" },
{ id: "Africa/Johannesburg", city: "Johannesburg", region: "Africa" },
{ id: "Asia/Dubai", city: "Dubai", region: "Asia" },
{ id: "Asia/Karachi", city: "Karachi", region: "Asia" },
{ id: "Asia/Kolkata", city: "Mumbai", region: "Asia" },
{ id: "Asia/Dhaka", city: "Dhaka", region: "Asia" },
{ id: "Asia/Bangkok", city: "Bangkok", region: "Asia" },
{ id: "Asia/Jakarta", city: "Jakarta", region: "Asia" },
{ id: "Asia/Singapore", city: "Singapore", region: "Asia" },
{ id: "Asia/Hong_Kong", city: "Hong Kong", region: "Asia" },
{ id: "Asia/Shanghai", city: "Shanghai", region: "Asia" },
{ id: "Asia/Taipei", city: "Taipei", region: "Asia" },
{ id: "Asia/Seoul", city: "Seoul", region: "Asia" },
{ id: "Asia/Tokyo", city: "Tokyo", region: "Asia" },
{ id: "Australia/Perth", city: "Perth", region: "Australia & Pacific" },
{ id: "Australia/Adelaide", city: "Adelaide", region: "Australia & Pacific" },
{ id: "Australia/Brisbane", city: "Brisbane", region: "Australia & Pacific" },
{ id: "Australia/Sydney", city: "Sydney", region: "Australia & Pacific" },
{ id: "Australia/Melbourne", city: "Melbourne", region: "Australia & Pacific" },
{ id: "Pacific/Auckland", city: "Auckland", region: "Australia & Pacific" },
{ id: "Pacific/Fiji", city: "Fiji", region: "Australia & Pacific" },
];
const REGION_ORDER = [
"Americas",
"UTC",
"Europe",
"Africa",
"Asia",
"Australia & Pacific",
];
/** Offset of an IANA zone at an instant, via Intl (no date libraries). */
export function zoneOffsetMinutes(tz: string, at: Date): number {
try {
const name =
new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
})
.formatToParts(at)
.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
const m = name.match(/GMT([+-])(\d{2}):(\d{2})/);
if (!m) return 0;
return (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
} catch {
return 0;
}
}
export function formatOffset(minutes: number): string {
const sign = minutes < 0 ? "−" : "+";
const abs = Math.abs(minutes);
return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
}
function zoneTime(tz: string, at: Date): string {
try {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(at);
} catch {
return "—";
}
}
export interface TimezoneSelectProps {
/** IANA zone id. */
value?: string;
defaultValue?: string;
onValueChange?: (zone: string) => void;
zones?: TimezoneOption[];
/** Instant used for offsets and the preview line. Pass a fixed date
* for deterministic renders. */
now?: Date;
disabled?: boolean;
placeholder?: string;
/** Form field name; submits the IANA id via a hidden input. */
name?: string;
className?: string;
"aria-label"?: string;
}
/**
* A searchable timezone picker: zones grouped by region and sorted by
* offset, tabular UTC offsets on every row, full keyboard navigation
* from the search field, and a selected-zone + UTC preview line.
*/
export function TimezoneSelect({
value: valueProp,
defaultValue = "America/New_York",
onValueChange,
zones = DEFAULT_TIMEZONES,
now,
disabled = false,
placeholder = "Select timezone",
name,
className,
"aria-label": ariaLabel = "Timezone",
}: TimezoneSelectProps) {
const listboxId = React.useId();
const [fallbackNow] = React.useState(() => new Date());
const at = now ?? fallbackNow;
const [valueState, setValueState] = React.useState(defaultValue);
const value = valueProp !== undefined ? valueProp : valueState;
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const listRef = React.useRef<HTMLDivElement>(null);
const atTime = at.getTime();
const withOffsets = React.useMemo(
() =>
zones
.map((z) => ({ ...z, offset: zoneOffsetMinutes(z.id, new Date(atTime)) }))
.sort((a, b) => a.offset - b.offset || a.city.localeCompare(b.city)),
[zones, atTime],
);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return withOffsets;
return withOffsets.filter(
(z) =>
z.city.toLowerCase().includes(q) ||
z.id.toLowerCase().replace(/_/g, " ").includes(q) ||
z.region.toLowerCase().includes(q) ||
formatOffset(z.offset).toLowerCase().includes(q.replace("-", "−")),
);
}, [withOffsets, query]);
const groups = React.useMemo(() => {
const byRegion = new Map<string, typeof filtered>();
for (const z of filtered) {
const list = byRegion.get(z.region) ?? [];
list.push(z);
byRegion.set(z.region, list);
}
return REGION_ORDER.filter((r) => byRegion.has(r)).map((r) => ({
region: r,
zones: byRegion.get(r)!,
}));
}, [filtered]);
const selected = withOffsets.find((z) => z.id === value);
const pick = (id: string) => {
setValueState(id);
onValueChange?.(id);
setOpen(false);
};
const optionId = (i: number) => `${listboxId}-opt-${i}`;
const onSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const next = Math.max(
0,
Math.min(filtered.length - 1, active + (e.key === "ArrowDown" ? 1 : -1)),
);
setActive(next);
listRef.current
?.querySelector(`#${CSS.escape(optionId(next))}`)
?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter") {
e.preventDefault();
const hit = filtered[active];
if (hit) pick(hit.id);
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
setActive(e.key === "Home" ? 0 : filtered.length - 1);
}
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuery("");
setActive(Math.max(0, withOffsets.findIndex((z) => z.id === value)));
}
}}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={ariaLabel}
className={cn(
"pressable flex h-9 w-72 items-center gap-2 rounded-lg bg-card px-3 text-sm shadow-border transition-[box-shadow,scale] duration-150 ease-out outline-none hover:shadow-border-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 dark:bg-secondary/30",
className,
)}
>
<Globe2 className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className={cn("min-w-0 flex-1 truncate text-left", !selected && "text-muted-foreground")}>
{selected ? selected.city : placeholder}
</span>
{selected && (
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatOffset(selected.offset)}
</span>
)}
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={8} className="w-80 p-0">
<div className="flex items-center gap-2 border-b border-border px-3">
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
role="combobox"
aria-expanded
aria-controls={listboxId}
aria-activedescendant={filtered[active] ? optionId(active) : undefined}
aria-label="Search timezones"
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={onSearchKeyDown}
placeholder="Search city, region, or offset…"
className="h-10 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Timezones"
className="max-h-64 overflow-y-auto p-1.5"
>
{groups.length === 0 && (
<p className="px-2.5 py-6 text-center text-sm text-muted-foreground">
No timezones match “{query}”.
</p>
)}
{groups.map((group) => (
<div key={group.region} role="group" aria-label={group.region}>
<div className="px-2.5 pt-2 pb-1 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase">
{group.region}
</div>
{group.zones.map((z) => {
const i = filtered.indexOf(z);
const isActive = i === active;
const isSelected = z.id === value;
return (
<div
key={z.id}
id={optionId(i)}
role="option"
aria-selected={isSelected}
onClick={() => pick(z.id)}
onPointerEnter={() => setActive(i)}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors duration-100 ease-out",
isActive && "bg-accent",
)}
>
<Check
aria-hidden
className={cn(
"size-3.5 shrink-0 text-foreground transition-opacity duration-100",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<span className="min-w-0 flex-1 truncate">{z.city}</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{formatOffset(z.offset)}
</span>
</div>
);
})}
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground tabular-nums">
<span className="min-w-0 truncate">
{selected ? `${selected.city} · ${zoneTime(selected.id, at)}` : "No zone selected"}
</span>
<span className="shrink-0">UTC · {zoneTime("UTC", at)}</span>
</div>
{name && <input type="hidden" name={name} value={value} />}
</PopoverContent>
</Popover>
);
}