Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Times are in Europe/London (BST, UTC+1).

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.

15 min
30 min
60 min

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.

Business hours16 rows
Full day48 rows

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.

24-hour
09:0014:3023:45
12-hour
9:00 am2:30 pm11:45 pm

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.

14:30 BST is 09:30 in New York and 22:30 in Tokyo.

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.

Idle field
Filled
Invalid
14:30
Slot idle
14:30
Slot selected
09:00
Slot disabled
2:30 pm
12-hour
Europe/London (BST)
Zone note

Anatomy

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.

  1. 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.

  2. TypeMonospace, tabular

    So a column of times aligns on the colon, and so the field does not shift as digits change.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. Zone noteUnder the field

    A time without a zone is ambiguous the moment a second person reads it.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--radius-mdField and slot corners
--radius-lgPanel corners

Shadow

TokenValueUsed for
--shadow-e4—Panel elevation

Typography

TokenValueUsed for
font-mono + tabular-nums—Times, so they align on the colon

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeMin widthTouch targetWhen to use
Small32px field—13px6rem—In a table filter or beside a compact date field.
Medium36px field—15px7rem—The default. 9rem when the format is 12-hour.
Large44px field—16px8rem—Touch layouts and booking flows.
Slot listmax 224px——9rem—About eight rows before scrolling, opened at the current value.
Slot30px0 8px——44px on coarse pointersDense — every row is a handful of characters.

Do

Constrain the range to what is valid09:00–17:00 at thirty minutes is sixteen rows. The same picker unconstrained is forty-eight, and two thirds of them can never be chosen.
selected?.scrollIntoView({ block: 'center' })
Open the list at the current valueA list that starts at 00:00 when the value is 17:30 hides the user’s own selection thirty-five rows down.
Let the field be typed into"1430" is four keystrokes. Scrolling to 14:30 in a list is a scroll and a click, and the user usually already knows the time.
Times are in Europe/London (BST, UTC+1).
State the time zoneA time with no zone is ambiguous the moment a second person reads it, and scheduling bugs from this assumption surface weeks later.

Don't

⌚
Do not use an analogue clock faceTwo dial interactions to express what four keystrokes say. It came from phones with no keyboard and it is slow even there.
Do not offer every minute1,440 rows is not a list. Almost every real time is round, and the few that are not can be typed.
Do not make the field full widthField width is a hint about the value. A time field stretched across a form reads as somewhere to type a sentence.
“Deploy at 02:00” → whose 02:00?
Do not assume the server’s time zoneYour 09:00 window is someone else’s 04:00. If users span regions, the zone is part of the value and needs its own control.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.5Identify Input PurposeAA2.1.1KeyboardA2.5.8Target Size (Minimum)AA3.3.2Labels or InstructionsA4.1.2Name, Role, ValueA

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 DownMoves by an hour, which is the useful jump in a list of thirty-minute slots.
Home / EndJumps to the first or last available slot.
0–9Types directly into the field. "1430" and "2:30 pm" must both parse.
EnterCommits 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.
AttributeApplied toNotes
role="combobox"The fieldWith aria-expanded and aria-activedescendant, exactly as in a Combobox. Focus never leaves the field.
role="listbox" / "option"The slot listWith aria-selected on the current value.
aria-labelEach slotThe spoken form: "half past two in the afternoon" is unnecessary, but "14:30" must be announced as a time, not as digits.
aria-describedbyThe fieldPoints at the zone note and the expected format, both read before typing.
aria-invalidThe fieldOn an unparseable value, with a message giving an example rather than a rule.
autocompleteThe fieldWhere the platform supports it, so a saved time can be filled.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
intervalnumber30Minutes between slots. Coarser is better wherever the task tolerates it.
min / maxnumber—Minutes since midnight. Constrains the list rather than merely rejecting on submit.
hour12booleanlocale defaultA 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.

Notes

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.