Time Pickers
Time pickers let users select a time using a clock dial or text input. They support 12-hour and 24-hour formats with smooth MD3 Expressive animations.
Time pickers provide an intuitive clock dial or text input interface for time selection. Built on MD3 Expressive motion principles with the same hoisted state architecture as Date Pickers.
Introduction
The MD3 Expressive Time Picker system mirrors the hoisted state architecture from the Date Picker — state is managed externally via useTimePickerState and passed down to display components.
The system provides:
- Two modes:
TimePicker(clock dial) andTimeInput(text fields) — toggleable in-dialog - 12h / 24h format — via
is24hourprop, defaulting tofalse(12h with AM/PM) - Responsive clock layout —
TimePickeruses the vertical Android arrangement in portrait and the horizontal arrangement on sufficiently wide landscape screens - Auto-advance: after selecting hour on the dial, automatically switches to minute selection
- MD3 Expressive spring animations — dialog enter/exit via
PICKER_CONTENT_ANIM
Anatomy
- Headline: "Select time" / "Enter time" label (labelMedium)
- Time Selectors: Hour and minute
<button>cells (TimePicker) or<input>fields (TimeInput) - Separator:
:character between hour and minute - Period Selector: AM/PM toggle (vertical layout) — hidden in 24h mode
- Clock Dial: SVG circle with 12 (or 24) numbers and draggable selector handle
- Action Buttons: Confirm and Cancel (in dialog mode)
- Mode Toggle: Keyboard ↔ Clock icon to switch between dial and input modes
Variants
Dial Mode (Clock)
Clock-based interface. Click or drag the selector handle to pick hour and minute.
Input Mode (Text)
Text field entry with auto-advance and validation. Suitable for power users or accessibility needs.
24-Hour Format
No AM/PM toggle — clock dial shows 0–23 in a dual-ring layout (outer 0–11, inner 12–23).
Features
Expressive Motion
- Dialog enter:
DEFAULT_SPATIAL_SPRING— scale + fade (reuse from DatePickerDialog) - Dialog exit: reverse spring animation
Auto-Switch Hour → Minute
After clicking or releasing the handle on hour mode, the dial automatically transitions to minute selection (autoSwitchToMinute = true by default).
Hoisted State Pattern
const state = useTimePickerState({
initialHour: 14,
initialMinute: 30,
is24hour: true,
});
// Read values at any time
console.log(state.hour); // 0-23, always valid
console.log(state.minute); // 0-59, always valid
console.log(state.isPm); // true if hour >= 12
Usage
Basic Dial Picker (with toggle)
import {
Button,
TimePicker,
TimeInput,
TimePickerDialog,
useTimePickerState,
} from "@bug-on/m3-expressive";
function MyTimePicker() {
const [open, setOpen] = React.useState(false);
const [mode, setMode] = React.useState<'dial' | 'input'>('dial');
const state = useTimePickerState({ initialHour: 9, initialMinute: 0 });
const handleConfirm = () => {
console.log(`${state.hour}:${String(state.minute).padStart(2, '0')}`);
setOpen(false);
};
return (
<>
<Button onClick={() => setOpen(true)}>Set Time</Button>
<TimePickerDialog
open={open}
onDismiss={() => setOpen(false)}
modeToggleButton={
<button onClick={() => setMode(m => m === 'dial' ? 'input' : 'dial')}>
<span className="md-icon">
{mode === 'dial' ? 'keyboard' : 'schedule'}
</span>
</button>
}
confirmButton={
<Button variant="text" onClick={handleConfirm}>OK</Button>
}
dismissButton={
<Button variant="text" onClick={() => setOpen(false)}>Cancel</Button>
}
>
{mode === 'dial'
? <TimePicker state={state} />
: <TimeInput state={state} />
}
</TimePickerDialog>
</>
);
}
24-Hour Format
const state = useTimePickerState({
initialHour: 14,
initialMinute: 30,
is24hour: true,
});
<TimePicker state={state} /> // No AM/PM selector
Clock Layout
TimePicker defaults to layout="auto". Use layout="vertical" or
layout="horizontal" to keep a specific Android MD3 arrangement.
<TimePicker state={state} layout="horizontal" />
Reading Selected Time
const state = useTimePickerState();
// hour is always 0-23 (internal 24h)
const { hour, minute, isPm } = state;
// Format for display (12h)
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
const label = `${String(hour12).padStart(2,'0')}:${String(minute).padStart(2,'0')} ${isPm ? 'PM' : 'AM'}`;
// Format for display (24h)
const label24 = `${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}`;
Best Practices
Do
- Always provide
modeToggleButtonin dialogs so users can switch between dial and keyboard input - Use
useTimePickerStateoutside the dialog to persist state between opens - Confirm
state.isInputValidbefore readingstate.hour/state.minutewhen usingTimeInput - Use
is24hour: truewhen locale/context requires 24h time (e.g., most of Europe/Asia)
Don't
- Don't commit the selected time until the user presses "OK"
- Don't override spring animations with CSS transitions — they break the MD3 feel
- Don't use
TimeInputwithout a confirm button — users need a way to commit their input
Design Tokens
Colors
| Role | CSS Variable | Light Value |
|---|---|---|
| Container | --md-sys-color-surface-container-high | #ece6f0 |
| Clock dial background | --md-sys-color-surface-container-highest | #e6e0e9 |
| Selector handle | --md-sys-color-primary | #6750a4 |
| Selected number text | --md-sys-color-on-primary | #ffffff |
| Selected time button | --md-sys-color-primary-container | #eaddff |
| Selected time text | --md-sys-color-on-primary-container | #21005d |
| Unselected time button | --md-sys-color-surface-container-highest | #e6e0e9 |
| AM/PM selected | --md-sys-color-tertiary-container | #ffd8e4 |
| AM/PM border | --md-sys-color-outline | #79747e |
| Input field (focused) | --md-sys-color-primary-container | #eaddff |
| Input focus outline | --md-sys-color-primary | #6750a4 |
Shape
| Element | Token | Value |
|---|---|---|
| Dialog container | CornerExtraLarge | 28px |
| Time selector buttons | CornerSmall | 8px |
| Period selector | CornerSmall | 8px |
| Clock dial | CornerFull | circle |
Dimensions
| Element | Width | Height |
|---|---|---|
| Clock dial | 256px | 256px |
| Selector handle | — | ∅48px |
| Time selector button | 96px | 80px |
| Period selector (vertical) | 52px | 80px |
| Time input field | 96px | 72px |
Accessibility
- Keyboard:
Tabnavigates between hour/minute/AM-PM;Enter/Spaceactivates - ARIA: Clock dial uses
role="img"; AM/PM is grouped in a labelled fieldset with pressed toggle buttons - Screen reader: Each interactive element has descriptive
aria-label - Input validation:
aria-invalidis set on input fields when value is out of range - Reduced Motion: Animations respect system
prefers-reduced-motionvia Framer Motion
API Reference
useTimePickerState(options?)
| Option | Type | Default | Description |
|---|---|---|---|
initialHour | number | 0 | Starting hour (0-23) |
initialMinute | number | 0 | Starting minute (0-59) |
is24hour | boolean | false | 24h format (no AM/PM) |
Returns TimePickerState:
| Property | Type | Description |
|---|---|---|
hour | number | Current hour (0-23), always valid |
minute | number | Current minute (0-59), always valid |
hourInput | number | Raw hour input (may be invalid mid-typing) |
minuteInput | number | Raw minute input (may be invalid mid-typing) |
isPm | boolean | true if hour >= 12 |
isInputValid | boolean | Both inputs are valid |
selection | 'hour' | 'minute' | Which field is active |
setHour(h) | function | Set hour (0-23) — for dial |
setMinute(m) | function | Set minute (0-59) — for dial |
setHourInput(h) | function | Set raw hour input — for text field |
setMinuteInput(m) | function | Set raw minute input — for text field |
setIsPm(pm) | function | Toggle AM/PM |
reset() | function | Reset to initial values |
TimePicker
| Prop | Type | Default | Description |
|---|---|---|---|
state | TimePickerState | required | State from useTimePickerState() |
layout | 'auto' | 'vertical' | 'horizontal' | 'auto' | Clock arrangement; auto uses horizontal in a wide landscape viewport |
className | string | — | Additional CSS classes |
TimeInput
| Prop | Type | Default | Description |
|---|---|---|---|
state | TimePickerState | required | State from useTimePickerState() |
className | string | — | Additional CSS classes |
TimePickerDialog
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | required | Open/close state |
onDismiss | () => void | required | Called on close (scrim, Escape) |
confirmButton | ReactNode | required | Confirm action (caller handles click) |
dismissButton | ReactNode | — | Cancel action button |
modeToggleButton | ReactNode | — | Keyboard ↔ Clock toggle icon |
title | string | "Select time" | Accessible dialog title |
children | ReactNode | required | <TimePicker> or <TimeInput> |
className | string | — | Additional CSS classes |