Time select
StableA time of day from a list in 15-minute steps, in the locale’s hour cycle. End-time lists show each option’s length.
Values are "HH:mm" in 24-hour time, like <input type="time">, while the list reads “9:30 AM” or “09:30” depending on the dates locale (setDateLocale; en-US by default). With durationFrom, only later times are offered and each shows its length (“1 hr 30 min”). Type digits in the open list to jump: 930 goes to 9:30.
Start and end times
import { useState } from 'react'import { TimeSelect } from '@rchursin/product/calendar'
/** HH:mm in and out. The end list starts after the start time and shows each option's length. */export default function TimeSelectBasic() { const [start, setStart] = useState('10:00') const [end, setEnd] = useState('11:30')
function changeStart(next: string) { setStart(next) // Keep the end after the start: push it to an hour later when it would come first if (end <= next) { const [h = 0, m = 0] = next.split(':').map(Number) const later = Math.min(h * 60 + m + 60, 23 * 60 + 45) setEnd(`${String(Math.floor(later / 60)).padStart(2, '0')}:${String(later % 60).padStart(2, '0')}`) } }
return ( <div className="grid w-full max-w-80 grid-cols-2 gap-3"> <TimeSelect label="Starts" value={start} onChange={changeStart} latest="23:30" /> <TimeSelect label="Ends" value={end} onChange={setEnd} durationFrom={start} /> <p className="col-span-2 font-mono text-[11px] text-fg-3"> start: "{start}" · end: "{end}" </p> </div> )}Usage
import { TimeSelect } from '@rchursin/product/calendar'<TimeSelect label="Starts" value={start} onChange={setStart} latest="23:30" /><TimeSelect label="Ends" value={end} onChange={setEnd} durationFrom={start} />Examples
Half-hour steps
step={30} for a daily send time, and a disabled field for a time someone else decides.
import { useState } from 'react'import { TimeSelect } from '@rchursin/product/calendar'
/** Half-hour steps for a daily send time, and a disabled field for a time someone else sets. */export default function TimeSelectSteps() { const [digest, setDigest] = useState('08:30')
return ( <div className="grid w-full max-w-80 grid-cols-2 gap-3"> <TimeSelect label="Daily digest" value={digest} onChange={setDigest} step={30} /> <TimeSelect label="Team standup" value="09:45" onChange={() => {}} disabled /> <p className="col-span-2 text-xs text-fg-3">The standup time is set by your team lead.</p> </div> )}API
Time select props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Visible label; names the field and the list. |
valuerequired | TimeValue | – | "HH:mm", 24-hour. A value between steps still shows, but the list opens at the top. |
onChangerequired | (value: TimeValue) => void | – | Called with the chosen "HH:mm". |
step | number | 15 | Minutes between options. Page Up and Page Down move by an hour’s worth. |
durationFrom | TimeValue | – | For end times: list only times after this one, each with its length. |
latest | TimeValue | '23:45' | Latest time offered. Pass "23:30" to a start list so an end always fits after it. |
hideLabel | boolean | – | Keep the label for screen readers only. |
disabled | boolean | – | Disables the field. |
className | string | – | Classes on the wrapper. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpaceDown arrowUp arrow | On the field: opens the list on the current time. |
| Up arrowDown arrow | Previous or next time. |
| Page UpPage Down | An hour earlier or later. |
| HomeEnd | First or last time. |
| 0123456789AP | Type to jump: 1 → 1:00, 93 or 930 → 9:30, 9p → 9:00 PM. Keys typed within about a second add up. |
| EnterSpace | Chooses the highlighted time and closes. |
| Esc | Closes without a change; focus returns to the field. |
| Tab | Closes the list. |
- The field is a button with aria-haspopup="listbox"; its name is the label plus the time shown.
- The open list is a listbox named by the label. Focus stays on the list and the highlighted option is set with aria-activedescendant; the chosen one is aria-selected and carries a check mark.
- The list scrolls itself to the highlighted time, so the page behind it never jumps.
- Times are written by Intl in the visitor’s hour cycle; lengths are words (“1 hr 30 min”), not a second number to decode.
Guidelines
Do
- Pair a start list with an end list that uses durationFrom.
- Keep the step to what the schedule really allows (15 or 30 minutes).
Don’t
- Don’t use it for durations (“how long”); use a length field.
- Don’t store the label (“9:30 AM”); store the value ("09:30").
In use
Related
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
- Event dialogThe create and edit form for an event, in a dialog, and the details popover that leads to it.
- PickerA filterable list of options, on its own or in a popover: type to filter, arrows and Enter to choose, or 1–9 to pick at once.
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
Source
The component’s own files, exactly as they run on this site.
/** * TimeSelect: a time of day from a list in 15-minute steps, written in the locale's hour cycle * ("9:30 AM" in the US, "09:30" in the UK). * * <TimeSelect label="Starts" value={start} onChange={setStart} /> * <TimeSelect label="Ends" value={end} onChange={setEnd} durationFrom={start} /> * * `value` is "HH:mm" (24-hour, like <input type="time">). With `durationFrom`, only later times * are listed and each shows its length ("1 hr 30 min"), as an end-time list should. * * Keys: ↓ ↑ or Enter on the button open the list on the current time. In the list: ↑ ↓ move, * Page Up / Down move an hour, Home / End, type digits to jump ("930" → 9:30), Enter or Space * choose, Esc or Tab close. Focus stays on the list (aria-activedescendant). */import { useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"import { cx } from "../../components/cx"import { Icon } from "../icons/Icon"import { Popover, usePopover } from "../ui/popover"import { formatSpan, formatTimeValue, minutesOf, timeValue, type TimeValue } from "./dates"import { useFieldIds } from "./field"