Skip to content

Timezone select

A searchable list of IANA time zones with city names, offsets and local times.

Status
Beta
Category
Selection
Adoption
Not used yet
import { TimezoneSelect } from "@oration/canon/components/timezone-select";
packages/canon/src/components/timezone-select.tsx

Workspace

Defaults for everyone at Cedarline.

Payment runs, reminder calls and business hours use this zone.
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 timeZoneCity and formatUtcOffset.
  • 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

Offsets and local times are set in tabular figures, and the offset uses a true minus sign, so the column of offsets aligns down the list.

The Machine Mono Rule

The IANA ID under each city, such as America/Chicago, is a machine string and is set in Geist Mono. The city and the offset stay in Geist Sans.

Anatomy#

ChicagoUTC−05:00
Search city, zone or offset
Americas
ChicagoAmerica/ChicagoUTC−05:0010:00 AM
  1. City. The chosen zone's city in 14px, truncated. America/Argentina/Buenos_Aires reads Buenos Aires, Argentina.
  2. 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.
  3. Search. An Input group at the top of the popup, Search city, zone or offset, focused when the list opens.
  4. Region label. Americas, Europe, Africa, Asia and so on, 12px Slate Meta, sticky while its zones scroll.
  5. City and zone ID. A 36px two-line row: the city in 13px and the IANA ID in 11px Geist Mono.
  6. 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.

Local time for the payment run approvers
ApproverCityOffsetAt the 3 PM UTC run
Maya OkaforChicagoUTC−05:0010:00 AM
Tomás FerreiraLisbonUTC+01:004:00 PM
Aisha BelloLagosUTC+01:004:00 PM
Priya RamanKolkataUTC+05:308:30 PM
Wen ZhouSingaporeUTC+08:0011: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#

Rest
Focus
Open
Disabled
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>    );}
States
StateTreatment
RestTransparent trigger with the Field Stroke. In dark, the input color at 30%, rising to 50% on hover.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%.
OpenThe trigger keeps its indigo border. The popup is 22rem wide and scrolls at 20rem, with the first match highlighted.
Highlighted and selectedThe highlighted row fills with the accent color; the chosen zone carries a check.
EmptyNo time zones match “…”. Try a city like Chicago.
Disabled50% opacity and a not-allowed cursor.

Behavior#

  • Controlled only: pass an IANA ID as value and update it in onValueChange. There's no empty state, so start from a saved zone or the browser's Intl.DateTimeFormat().resolvedOptions().timeZone.
  • The list comes from Intl.supportedValuesOf("timeZone"), falling back to 28 common zones on older browsers. UTC is always included, and a value missing 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.
  • hour12 only 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.

Which select to use
ComponentReach for it whenList lengthRows show
SelectRows 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 15Anything: icons, tags, two-line rows
Option selectA flat list of strings or { value, label } pairs in a dense inspector, run bar or toolbar. One line, full width, 13px.2 to about 15Text
Select fieldA 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 15Text
Native selectThe platform picker is the better control: phone-first forms, or a plain list that must post with a native form.Any, plain labelsText, grouped by optgroup
ComboboxPeople, suppliers, invoices or any list long enough to search, or several values shown as removable chips.About 15 or more, or unknownAnything; filters as you type
Timezone select (this page)A time zone. Always, instead of a hand-written list of zones.Every IANA zoneCity, zone, offset and local time

Do and don't#

Do. Store an IANA ID and let people search every zone by city.
Don't. Offer a short list of abbreviations. Lisbon, Lagos and Kolkata aren't in it, and CST is wrong half the year.

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 Label pointed at its id, or aria-label when there's no visible label.
  • Link a description with aria-describedby; in a settings row, use descriptionId(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.
Keyboard interactions
KeysAction
EnterOpens the list from the trigger. Space does too.
A–ZTyping in the search box filters the zones.
↓↑Moves the highlight through the matches.
EnterChooses the highlighted zone and closes the list.
EscCloses the list, clears the search and returns focus to the trigger.

Design tokens#

Design tokens
TokenUsed for
--inputField Stroke; 30% and 50% fills in dark
--ringFocus and open border, 3px focus ring at 50%
--popoverPopup surface and the sticky region label
--accentHighlighted row
--muted-foregroundOffset, zone ID, local time, chevron
--font-monoIANA zone IDs
--radius-lg10px trigger and popup corners

API reference#

TimezoneSelect

The trigger and the searchable, grouped list.

Props of TimezoneSelect
PropTypeDefaultDescription
valueRequiredstringNo defaultThe IANA ID, such as America/Chicago.
onValueChangeRequired(value: string) => voidNo defaultCalled with the chosen IANA ID.
idstringNo defaultSet on the trigger, for a Label's htmlFor.
aria-labelstringNo defaultNames the trigger when there's no visible label.
aria-describedbystringNo defaultLinks a description or error to the trigger.
hour12booleantrue12-hour local times in the rows; false for 24-hour.
disabledbooleanNo defaultDims the trigger and blocks opening.
classNamestringNo defaultMerged 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.