Time Picker
Discrete time entry. A stepped list beats a clock face on every device that has a keyboard — and most of the ones that do not.
Also called Clock Picker, Duration Input — in this system all of them are Time Picker.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The interval is the design
The same list at 15, 30 and 60 minutes. Ninety-six rows is a scroll; twenty-four is a choice. Pick the coarsest interval the task tolerates.
Constrain the range
A booking window of 09:00–17:00 at 30 minutes is sixteen rows. The same picker unconstrained is forty-eight, and thirty-two of them are never valid.
12-hour and 24-hour
A locale preference, not a design one. 24-hour is unambiguous and sorts correctly; 12-hour is what most of the US and UK read naturally. Follow the user’s locale.
Time zones are part of the value
A time with no zone is ambiguous between any two people. State it under the field, and make it editable where users span regions.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A typeable field with a clock affordance, and a list of increments that opens scrolled to the current value.
- Field width7–9rem
Sized to the longest format — "11:45 pm" is wider than "23:45". Never full width: the field’s width is a hint about what goes in it.
- TypeMonospace, tabular
So a column of times aligns on the colon, and so the field does not shift as digits change.
- List width9rem, matched to the field
Narrow, because every row is five to eight characters. A full-width list of times looks like a mistake.
- Slot height30px (44px on touch)
Dense, because the list is scanned rather than read. Touch needs the full 44px or adjacent slots are mis-tapped.
- Initial scrollCurrent value centred
A list that opens at 00:00 when the value is 17:30 makes the user scroll past thirty-five rows to see where they are.
- Interval15 / 30 / 60 min
The design decision. Coarser is better wherever the task tolerates it — the list length is the interval divided into the range.
- Zone noteUnder the field
A time without a zone is ambiguous the moment a second person reads it.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Field fill |
| --ds-border-interactive | — | Field border |
| --ds-accent | — | Focus border |
| --ds-surface-overlay | — | Slot list panel |
| --ds-accent-subtle | — | Selected slot |
| --ds-layer-hover | — | Slot hover |
| --ds-fg-muted | — | Clock icon and the zone note |
| --ds-fg-disabled | — | Unavailable slots |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Field and slot corners | |
| --radius-lg | Panel corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono + tabular-nums | — | Times, so they align on the colon |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Small | 32px field | — | 13px | 6rem | — | In a table filter or beside a compact date field. |
| Medium | 36px field | — | 15px | 7rem | — | The default. 9rem when the format is 12-hour. |
| Large | 44px field | — | 16px | 8rem | — | Touch layouts and booking flows. |
| Slot list | max 224px | — | — | 9rem | — | About eight rows before scrolling, opened at the current value. |
| Slot | 30px | 0 8px | — | — | 44px on coarse pointers | Dense — every row is a handful of characters. |
selected?.scrollIntoView({ block: 'center' })Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The selected slot must be distinguishable from the hover state, or the user cannot tell what is chosen while the pointer is in the list.
- Disabled slots may use the disabled tone, and must also carry the disabled attribute so the unavailability is exposed rather than merely dimmed.
- The zone note is content and owes 4.5:1 — it is part of the value.
- Monospace times at 12px still owe 4.5:1; small tabular figures are easy to under-contrast.
Keyboard
| ↓ | Opens the list and highlights the current value. Focus stays in the field. |
| ↑ / ↓ | Moves the highlight one slot, wrapping at the ends. |
| Page Up / Page Down | Moves by an hour, which is the useful jump in a list of thirty-minute slots. |
| Home / End | Jumps to the first or last available slot. |
| 0–9 | Types directly into the field. "1430" and "2:30 pm" must both parse. |
| Enter | Commits the highlighted slot; Esc closes without changing anything. |
Screen readers
- Announce the slot count when the list opens: "16 times available".
- Announce the zone with the value on commit: "14:30, Europe/London".
- For constrained ranges, say why in the description — "Between 09:00 and 17:00" — rather than leaving the user to discover it by finding rows disabled.
Focus & touch
- Focus stays in the field for the whole interaction, with the highlight moved by aria-activedescendant. Choosing a slot closes the list and leaves focus in the field with the value filled, so the user can immediately correct it.
- Slots go to 44px, and native input[type=time] is worth serious consideration on mobile — the platform picker is familiar and free. Set inputmode="numeric" on the field so the numeric keypad appears. A 12-hour picker on touch should show am/pm as a segmented control rather than expecting the user to type it.
| Attribute | Applied to | Notes |
|---|---|---|
| role="combobox" | The field | With aria-expanded and aria-activedescendant, exactly as in a Combobox. Focus never leaves the field. |
| role="listbox" / "option" | The slot list | With aria-selected on the current value. |
| aria-label | Each slot | The spoken form: "half past two in the afternoon" is unnecessary, but "14:30" must be announced as a time, not as digits. |
| aria-describedby | The field | Points at the zone note and the expected format, both read before typing. |
| aria-invalid | The field | On an unparseable value, with a message giving an example rather than a rule. |
| autocomplete | The field | Where the platform supports it, so a saved time can be filled. |
Example usage
1import { TimePicker } from '@/ui/Input'23<Field label="Deployment window" description="Times are in Europe/London (BST).">4 <TimePicker5 value={time} // minutes since midnight6 onChange={setTime}7 interval={30} // the design decision8 min={9 * 60} // constrain to what is valid…9 max={17 * 60} // …and the list stops being a scroll10 hour12={locale.hour12}11 />12</Field>1314// Parse loosely. "1430", "14:30", "2:30 pm" and "2.30pm" are one time.15function parseTime(input: string): number | null {16 const s = input.trim().toLowerCase()17 const m = /^(\d{1,2})[:.]?(\d{2})?\s*(am|pm)?$/.exec(s)18 if (!m) return null19 let h = Number(m[1])20 const min = Number(m[2] ?? 0)21 if (m[3] === 'pm' && h < 12) h += 1222 if (m[3] === 'am' && h === 12) h = 023 if (h > 23 || min > 59) return null24 return h * 60 + min25}2627// Open at the current value. A list that starts at 00:00 hides the user's28// own selection thirty-five rows down.29React.useEffect(() => {30 if (!open) return31 listRef.current32 ?.querySelector('[aria-selected="true"]')33 ?.scrollIntoView({ block: 'center' })34}, [open])3536// Store the instant, not the wall-clock time, whenever a date is involved.37const instant = zonedTimeToUtc(setMinutes(setHours(date, h), m), zone)Framework-free HTML
<div class="ds-field">
<label for="time">Deployment window</label>
<p id="time-hint">Times are in Europe/London (BST). Between 09:00 and 17:00.</p>
<input
id="time"
type="text"
inputmode="numeric"
role="combobox"
aria-expanded="true"
aria-controls="time-list"
aria-activedescendant="slot-1730"
aria-describedby="time-hint"
autocomplete="off"
value="17:30"
/>
</div>
<ul id="time-list" role="listbox" aria-label="Time">
<li id="slot-1700" role="option" aria-selected="false">17:00</li>
<li id="slot-1730" role="option" aria-selected="true">17:30</li>
<li id="slot-1800" role="option" aria-selected="false" aria-disabled="true">18:00</li>
</ul>CSS
.ds-timepicker input {
/* Width is a hint about the value. "11:45 pm" is the widest case. */
inline-size: 7rem;
block-size: 36px;
padding-inline: 12px 32px;
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
/* Aligns a column of times on the colon and stops the field shifting. */
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.ds-timepicker__list {
inline-size: 9rem;
max-block-size: 224px; /* ~8 rows, then scroll */
overflow-y: auto;
padding: 4px;
border: 1px solid var(--ds-border);
border-radius: var(--radius-lg);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e4);
/* Slots snap so a flick lands on a row rather than between two. */
scroll-snap-type: y proximity;
}
[role='option'] {
block-size: 30px;
padding-inline: 8px;
border-radius: var(--radius-md);
scroll-snap-align: center;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--ds-fg-secondary);
}
[role='option'][aria-selected='true'] {
background: var(--ds-accent-subtle);
color: var(--ds-fg);
}
[role='option'][aria-disabled='true'] { color: var(--ds-fg-disabled); }
@media (pointer: coarse) {
[role='option'] { block-size: 44px; }
.ds-timepicker input { block-size: 44px; }
}Component API
TimePicker
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | null | — | Minutes since midnight. Not a Date — a time of day has no date attached. |
| onChange* | (v: number | null) => void | — | Fires on slot selection and on a successful parse from the field. |
| interval | number | 30 | Minutes between slots. Coarser is better wherever the task tolerates it. |
| min / max | number | — | Minutes since midnight. Constrains the list rather than merely rejecting on submit. |
| hour12 | boolean | locale default | A locale preference, not a design one. |
| disabledTimes | (mins: number) => boolean | — | For availability. Disabled slots stay in the list so the pattern of availability is visible. |
Professional tips
- Pair the picker with a duration rather than an end time where you can. "30 minutes" is easier to choose than "15:00", and it stays correct when the start moves.
- Show the equivalent in the viewer’s zone when scheduling across regions: "14:30 BST — 09:30 your time" prevents most double-bookings.
- Default to the next sensible slot rather than empty. The next half hour, or the start of business hours, removes an interaction for most users.
- Show slot availability inline for booking flows. A greyed 14:30 with "fully booked" beside it is more useful than a hidden row.
- On mobile, consider native input[type=time] outright — familiar, accessible, and free to maintain.
Performance
- Generate the slot list once per range and interval, memoised. Rebuilding forty-eight rows on every keystroke while the user types is unnecessary work.
- Virtualise only if you have somehow ended up with a minute-level list, which is itself the problem to fix.
- Format times with a cached Intl.DateTimeFormat instance. Constructing one per row is a measurable cost in a long list.
- Keep the value as minutes since midnight rather than a Date. It sidesteps daylight-saving arithmetic entirely until a date is actually attached.
Common mistakes
- An analogue clock face, which is slower than typing on every device.
- Minute-level granularity, producing a list nobody can use.
- A list that opens at 00:00 instead of the current value.
- A read-only field, so "1430" cannot be typed.
- No time zone stated, making the value ambiguous between any two people.
- A full-width field, misrepresenting how much goes in it.
- Proportional figures, so a column of times does not align on the colon.
- Availability enforced only on submit rather than shown in the list.
Real-world recommendations
- Booking flows are where this control matters most, and availability is the real content — the times themselves are trivial by comparison.
- Cross-zone scheduling is where teams lose the most time. Showing both zones side by side costs one line and prevents a category of mistake.
- Users type far more than they scroll once they know the field accepts it. Loose parsing is worth more than any refinement to the list.
- For anything recurring, the time is usually a preference rather than a per-instance choice. Consider collecting it once in settings instead of on every form.