MD3
Expressive
MATERIAL DESIGN 3 EXPRESSIVE

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) and TimeInput (text fields) — toggleable in-dialog
  • 12h / 24h format — via is24hour prop, defaulting to false (12h with AM/PM)
  • Responsive clock layoutTimePicker uses 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.

Loading demo...

Input Mode (Text)

Text field entry with auto-advance and validation. Suitable for power users or accessibility needs.

Loading demo...

24-Hour Format

No AM/PM toggle — clock dial shows 0–23 in a dual-ring layout (outer 0–11, inner 12–23).

Loading demo...

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 modeToggleButton in dialogs so users can switch between dial and keyboard input
  • Use useTimePickerState outside the dialog to persist state between opens
  • Confirm state.isInputValid before reading state.hour/state.minute when using TimeInput
  • Use is24hour: true when 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 TimeInput without a confirm button — users need a way to commit their input

Design Tokens

Colors

RoleCSS VariableLight 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

ElementTokenValue
Dialog containerCornerExtraLarge28px
Time selector buttonsCornerSmall8px
Period selectorCornerSmall8px
Clock dialCornerFullcircle

Dimensions

ElementWidthHeight
Clock dial256px256px
Selector handle∅48px
Time selector button96px80px
Period selector (vertical)52px80px
Time input field96px72px

Accessibility

  • Keyboard: Tab navigates between hour/minute/AM-PM; Enter/Space activates
  • 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-invalid is set on input fields when value is out of range
  • Reduced Motion: Animations respect system prefers-reduced-motion via Framer Motion

API Reference

useTimePickerState(options?)

OptionTypeDefaultDescription
initialHournumber0Starting hour (0-23)
initialMinutenumber0Starting minute (0-59)
is24hourbooleanfalse24h format (no AM/PM)

Returns TimePickerState:

PropertyTypeDescription
hournumberCurrent hour (0-23), always valid
minutenumberCurrent minute (0-59), always valid
hourInputnumberRaw hour input (may be invalid mid-typing)
minuteInputnumberRaw minute input (may be invalid mid-typing)
isPmbooleantrue if hour >= 12
isInputValidbooleanBoth inputs are valid
selection'hour' | 'minute'Which field is active
setHour(h)functionSet hour (0-23) — for dial
setMinute(m)functionSet minute (0-59) — for dial
setHourInput(h)functionSet raw hour input — for text field
setMinuteInput(m)functionSet raw minute input — for text field
setIsPm(pm)functionToggle AM/PM
reset()functionReset to initial values

TimePicker

PropTypeDefaultDescription
stateTimePickerStaterequiredState from useTimePickerState()
layout'auto' | 'vertical' | 'horizontal''auto'Clock arrangement; auto uses horizontal in a wide landscape viewport
classNamestringAdditional CSS classes

TimeInput

PropTypeDefaultDescription
stateTimePickerStaterequiredState from useTimePickerState()
classNamestringAdditional CSS classes

TimePickerDialog

PropTypeDefaultDescription
openbooleanrequiredOpen/close state
onDismiss() => voidrequiredCalled on close (scrim, Escape)
confirmButtonReactNoderequiredConfirm action (caller handles click)
dismissButtonReactNodeCancel action button
modeToggleButtonReactNodeKeyboard ↔ Clock toggle icon
titlestring"Select time"Accessible dialog title
childrenReactNoderequired<TimePicker> or <TimeInput>
classNamestringAdditional CSS classes