Timezone select
A searchable list of IANA time zones with city names, offsets and local times.
Workspace
Defaults for everyone at Cedarline.
import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { TimezoneSelect, timeZoneCity } from "@oration/canon/components/timezone-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const id = React.useId(); const [zone, setZone] = React.useState("America/Chicago"); return ( <div className="w-full max-w-2xl text-left"> <SettingsSection title="Workspace" description="Defaults for everyone at Cedarline." > <SettingsGroup> <SettingsRow label="Time zone" description="Payment runs, reminder calls and business hours use this zone." htmlFor={id} > <TimezoneSelect id={id} aria-describedby={descriptionId(id)} value={zone} onValueChange={(next) => { setZone(next); toast.add({ type: "success", title: "Time zone saved", description: `Payment runs now follow ${timeZoneCity(next)} time.`, }); }} /> </SettingsRow> </SettingsGroup> </SettingsSection> </div> );}Usage#
Timezone select picks an IANA time zone from every zone the browser knows, grouped by region, with each zone's current UTC offset and local time. Search matches city, zone ID, region or offset, so tokyo, Asia/ and +05:30 all find something. Business hours picker uses it, and so should anything that stores a zone. The common mistake is a hand-written list of four abbreviations: people outside it can't find their city, and labels like CST go wrong the day daylight saving changes.
When to use
- Whenever a setting stores a time zone: the workspace zone, a supplier's calling hours, a payment run's schedule.
- Beside business hours or a scheduled time, so people see the offset and the local time they're committing to.
- In a settings row, where its 288px trigger matches the other controls in the column.
When not to use
- To show a zone that can't be changed. Render the city and offset as text with
timeZoneCityandformatUtcOffset. - For a short list of regions or offices that aren't zones. Use Select field
- For a full weekly schedule with a zone. The composed picker already includes it. Use Business hours picker
- For people, suppliers or other long searchable lists. Use Combobox
The Tabular Figures Rule
The Machine Mono Rule
America/Chicago, is a machine string and is set in Geist Mono. The city and the offset stay in Geist Sans.Anatomy#
- City. The chosen zone's city in 14px, truncated.
America/Argentina/Buenos_Airesreads Buenos Aires, Argentina. - Offset and chevron. The current UTC offset in Slate Meta tabular figures, then a 16px chevron. The trigger is 32px with 10px corners, full width and 288px from 640px up.
- Search. An Input group at the top of the popup, Search city, zone or offset, focused when the list opens.
- Region label. Americas, Europe, Africa, Asia and so on, 12px Slate Meta, sticky while its zones scroll.
- City and zone ID. A 36px two-line row: the city in 13px and the IANA ID in 11px Geist Mono.
- Offset and local time. 12px Slate Meta tabular figures at the right: the offset and the current time in that zone.
Examples#
Label and description
Point a Label at the trigger's id and link the hint with aria-describedby. The trigger fills its column below 640px and is 288px above; override with className.
Reminder calls only go out between 9 AM and 5 PM here.
import { Label } from "@oration/canon/components/label";import { TimezoneSelect, timeZoneCity } from "@oration/canon/components/timezone-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function WithLabel() { const id = React.useId(); const [zone, setZone] = React.useState("Europe/Lisbon"); return ( <div className="flex w-full max-w-xs flex-col gap-1.5 text-left"> <Label htmlFor={id}>Supplier time zone</Label> <TimezoneSelect id={id} aria-describedby={`${id}-hint`} value={zone} onValueChange={(next) => { setZone(next); toast.add({ title: "Time zone changed", description: `Reminder calls to Orchard Street go out in ${timeZoneCity(next)} hours.`, }); }} className="sm:w-full" /> <p id={`${id}-hint`} className="text-xs text-muted-foreground"> Reminder calls only go out between 9 AM and 5 PM here. </p> </div> );}With a time format
hour12 sets the local time preview in each row. Drive it from the same 12h or 24h control the rest of the schedule uses, as Business hours picker does.
import { Label } from "@oration/canon/components/label";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { TimezoneSelect, timeZoneCity } from "@oration/canon/components/timezone-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function BusinessHours() { const id = React.useId(); const [zone, setZone] = React.useState("America/New_York"); const [format, setFormat] = React.useState<"12" | "24">("12"); return ( <div className="flex w-full max-w-lg flex-wrap items-end justify-between gap-3 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex min-w-0 flex-col gap-1.5"> <Label htmlFor={id} className="text-13"> Time zone </Label> <TimezoneSelect id={id} value={zone} onValueChange={(next) => { setZone(next); toast.add({ title: "Business hours moved", description: `Support hours now follow ${timeZoneCity(next)}.`, }); }} hour12={format === "12"} /> </div> <SegmentedControl label="Time format" value={format} onValueChange={setFormat} options={[ { value: "12", label: "12h" }, { value: "24", label: "24h" }, ]} /> </div> );}Formatting helpers
timeZoneCity, offsetMinutes, formatUtcOffset and formatLocalTime are exported, so a zone reads the same in a table or a summary as it does in the picker.
| Approver | City | Offset | At the 3 PM UTC run |
|---|---|---|---|
| Maya Okafor | Chicago | UTC−05:00 | 10:00 AM |
| Tomás Ferreira | Lisbon | UTC+01:00 | 4:00 PM |
| Aisha Bello | Lagos | UTC+01:00 | 4:00 PM |
| Priya Raman | Kolkata | UTC+05:30 | 8:30 PM |
| Wen Zhou | Singapore | UTC+08:00 | 11:00 PM |
import { formatLocalTime, formatUtcOffset, offsetMinutes, timeZoneCity,} from "@oration/canon/components/timezone-select";export function Helpers() { const at = new Date("2026-09-28T15:00:00Z"); const team = [ { name: "Maya Okafor", zone: "America/Chicago" }, { name: "Tomás Ferreira", zone: "Europe/Lisbon" }, { name: "Aisha Bello", zone: "Africa/Lagos" }, { name: "Priya Raman", zone: "Asia/Kolkata" }, { name: "Wen Zhou", zone: "Asia/Singapore" }, ]; return ( <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border"> <table className="w-full text-13"> <caption className="sr-only"> Local time for the payment run approvers </caption> <thead> <tr className="border-b border-border text-xs text-muted-foreground"> <th scope="col" className="h-8 px-3 text-left font-medium" > Approver </th> <th scope="col" className="h-8 px-3 text-left font-medium" > City </th> <th scope="col" className="h-8 px-3 text-right font-medium" > Offset </th> <th scope="col" className="h-8 px-3 text-right font-medium" > At the 3 PM UTC run </th> </tr> </thead> <tbody> {team.map((person) => ( <tr key={person.name} className="border-b border-border last:border-0" > <td className="h-9 px-3">{person.name}</td> <td className="h-9 px-3 text-muted-foreground"> {timeZoneCity(person.zone)} </td> <td className="h-9 px-3 text-right tabular-nums"> {formatUtcOffset( offsetMinutes(person.zone, at), )} </td> <td className="h-9 px-3 text-right tabular-nums"> {formatLocalTime(person.zone, at)} </td> </tr> ))} </tbody> </table> </div> );}Disabled
When the zone is set elsewhere, disable the control and say who can change it.
Set by Maya Okafor for the whole workspace. Ask a workspace admin to change it.
import { Label } from "@oration/canon/components/label";import { TimezoneSelect } from "@oration/canon/components/timezone-select";import * as React from "react";export function Disabled() { const id = React.useId(); return ( <div className="flex w-full max-w-xs flex-col gap-1.5 text-left"> <Label htmlFor={id}>Time zone</Label> <TimezoneSelect id={id} aria-describedby={`${id}-hint`} value="America/Chicago" onValueChange={() => undefined} disabled className="sm:w-full" /> <p id={`${id}-hint`} className="text-xs text-muted-foreground"> Set by Maya Okafor for the whole workspace. Ask a workspace admin to change it. </p> </div> );}States#
import { TimezoneSelect } from "@oration/canon/components/timezone-select";import { cn } from "@oration/canon/lib/utils";export function StatesRow() { const states = [ { name: "Rest", className: "", disabled: false }, { name: "Focus", className: "border-ring ring-3 ring-ring/50", disabled: false, }, { name: "Open", className: "border-ring", disabled: false }, { name: "Disabled", className: "", disabled: true }, ]; return ( <div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2"> {states.map((state) => ( <div key={state.name} className="flex flex-col gap-1.5"> <span className="text-xs text-muted-foreground"> {state.name} </span> <TimezoneSelect aria-label={`Time zone, ${state.name.toLowerCase()}`} value="Asia/Singapore" onValueChange={() => undefined} disabled={state.disabled} className={cn( "pointer-events-none sm:w-full", state.className, )} /> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Transparent trigger with the Field Stroke. In dark, the input color at 30%, rising to 50% on hover. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50%. |
| Open | The trigger keeps its indigo border. The popup is 22rem wide and scrolls at 20rem, with the first match highlighted. |
| Highlighted and selected | The highlighted row fills with the accent color; the chosen zone carries a check. |
| Empty | No time zones match “…”. Try a city like Chicago. |
| Disabled | 50% opacity and a not-allowed cursor. |
Behavior#
- Controlled only: pass an IANA ID as
valueand update it inonValueChange. There's no empty state, so start from a saved zone or the browser'sIntl.DateTimeFormat().resolvedOptions().timeZone. - The list comes from
Intl.supportedValuesOf("timeZone"), falling back to 28 common zones on older browsers. UTC is always included, and avaluemissing from the list is added so it still shows. - Within a region, zones sort by offset, then by city. Search ignores case and treats a true minus sign and a hyphen alike; the text clears when the list closes.
- Offsets are for the current moment, so they follow daylight saving: Chicago reads UTC−05:00 in summer and UTC−06:00 in winter.
- Nothing is computed until the list opens, and during server rendering the local times are omitted. While open, local times refresh every 30 seconds.
hour12only changes the local time preview in the rows. Pair it with the same 12h or 24h setting used elsewhere on the page.
Choosing a select#
Six components pick a value from a list. A time zone is always Timezone select. Past about 15 options, or for several values, use Combobox. Otherwise decide by what a row has to show and where the control sits.
| Component | Reach for it when | List length | Rows show |
|---|---|---|---|
| Select | Rows need icons, tags, groups or separators, the trigger shows a formatted value, or people pick several values from a short list. You compose the parts. | 2 to about 15 | Anything: icons, tags, two-line rows |
| Option select | A flat list of strings or { value, label } pairs in a dense inspector, run bar or toolbar. One line, full width, 13px. | 2 to about 15 | Text |
| Select field | A typed value in a settings row, agent config page, sheet form or filter bar. The preset sets the width and height for that surface. | 2 to about 15 | Text |
| Native select | The platform picker is the better control: phone-first forms, or a plain list that must post with a native form. | Any, plain labels | Text, grouped by optgroup |
| Combobox | People, suppliers, invoices or any list long enough to search, or several values shown as removable chips. | About 15 or more, or unknown | Anything; filters as you type |
| Timezone select (this page) | A time zone. Always, instead of a hand-written list of zones. | Every IANA zone | City, zone, offset and local time |
Do and don't#
Content#
- Label it Time zone. Say in the description what follows the zone: Payment runs, reminder calls and business hours use this zone.
- Show cities, not abbreviations. Chicago, UTC−05:00 is unambiguous; CST isn't.
- When a zone is locked, say who set it and who can change it, beside the control.
Accessibility#
- The trigger is a button with the Base UI combobox semantics. Name it with a
Labelpointed at itsid, oraria-labelwhen there's no visible label. - Link a description with
aria-describedby; in a settings row, usedescriptionId(id). - The search input is labelled Search time zones, and focus moves into it when the list opens.
- Offsets use a true minus sign, which screen readers read as minus.
| Keys | Action |
|---|---|
| Enter | Opens the list from the trigger. Space does too. |
| A–Z | Typing in the search box filters the zones. |
| ↓↑ | Moves the highlight through the matches. |
| Enter | Chooses the highlighted zone and closes the list. |
| Esc | Closes the list, clears the search and returns focus to the trigger. |
Design tokens#
| Token | Used for |
|---|---|
--input | Field Stroke; 30% and 50% fills in dark |
--ring | Focus and open border, 3px focus ring at 50% |
--popover | Popup surface and the sticky region label |
--accent | Highlighted row |
--muted-foreground | Offset, zone ID, local time, chevron |
--font-mono | IANA zone IDs |
--radius-lg | 10px trigger and popup corners |
API reference#
TimezoneSelect
The trigger and the searchable, grouped list.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The IANA ID, such as America/Chicago. |
onValueChangeRequired | (value: string) => void | No default | Called with the chosen IANA ID. |
id | string | No default | Set on the trigger, for a Label's htmlFor. |
aria-label | string | No default | Names the trigger when there's no visible label. |
aria-describedby | string | No default | Links a description or error to the trigger. |
hour12 | boolean | true | 12-hour local times in the rows; false for 24-hour. |
disabled | boolean | No default | Dims the trigger and blocks opening. |
className | string | No default | Merged onto the trigger, for width. |
listTimeZones
() => string[]. Every IANA zone the runtime knows, plus UTC, cached after the first call.
No props of its own.
timeZoneCity
(tz: string) => string. A readable city: Buenos Aires, Argentina for America/Argentina/Buenos_Aires, Coordinated Universal Time for UTC.
No props of its own.
offsetMinutes
(tz: string, at: Date) => number. The zone's offset from UTC in minutes at a moment, 0 if the zone is unknown.
No props of its own.
formatUtcOffset
(minutes: number) => string. UTC−04:00 with a true minus sign, or UTC for zero.
No props of its own.
formatLocalTime
(tz: string, at: Date, hour12 = true) => string. The time at a moment in a zone, such as 10:00 AM.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Zone IDs in the rows are 11px (text-[0.6875rem]). The Thirteen-Fourteen Rule keeps 11px for footnotes; meta text is 12px.
There's no invalid state or aria-invalid prop, no size prop and no light-mode hover fill, unlike Select.
No pinned group for the browser's own zone or recent picks, so people search even for their own city.
In the app it renders only inside Business hours picker; the settings pages import timeZoneCity alone.