Time pickers

A time picker asks for a time of day. Drag the handle around the clock dial, or switch to input mode and type it. DockedTimePicker shows it in a popover under a text field, TimePickerDialog shows it in a modal, and ClockDial is the dial alone for your own layout.

value is an HH:mm string on a 24 hour clock, whatever clock is shown, so '20:00' and '08:00 PM' are the same value. For a date and a time, use the date and time picker. For a date only, use the date picker.

Usage

value is bindable and stays in sync with typing and with the dial. A pick in the panel is pending until OK confirms it. Cancel discards it and keeps the previous value.

The text field accepts a time typed in the locale's format, and the supporting text shows that format as a hint. On a 12 hour clock you must type the day period too, since 07:30 alone could be AM or PM.

Value: 14:30

<script lang="ts">
	import { DockedTimePicker } from 'noph-ui'

	let value = $state<string | undefined>('14:30')
</script>

<DockedTimePicker bind:value label="Time" />
<p>Value: <code>{value ?? 'undefined'}</code></p>

The dial and the input

Both pickers have a toggle in the bottom left that swaps the dial for two number fields. mode is bindable, so a page can choose which one opens. The dial is faster for a rough time on a touch screen. The input mode is faster for an exact time, and it works without a pointer. Set modeToggle to false to keep only one mode.

Tapping the hour ring moves on to the minute automatically. With the keyboard, focus stays on the hour, so you can still adjust it after choosing it.

Value: 07:00

Mode: dial

<script lang="ts">
	import { Button, TimePickerDialog } from 'noph-ui'

	let value = $state<string | undefined>('07:00')
	let open = $state(false)
	let mode = $state<'dial' | 'input'>('dial')
</script>

<Button variant="filled" onclick={() => (open = true)}>Select time</Button>
<TimePickerDialog bind:value bind:open bind:mode hour12 />
<p>Value: <code>{value ?? 'undefined'}</code></p>
<p>Mode: <code>{mode}</code></p>

12 and 24 hour clocks

By default the clock follows the locale. hour12 overrides it, and with it whether there is an AM/PM selector.

The 24 hour dial has two rings: 00 to 11 on the outside and 12 to 23 on the inside. The distance from the centre picks the ring, so every hour is one gesture away. Without a period selector, the hour and minute fields widen from 96dp to 114dp, as the spec asks.

<script lang="ts">
	import { DockedTimePicker } from 'noph-ui'

	let twelve = $state<string | undefined>('20:00')
	let twentyFour = $state<string | undefined>('20:00')
</script>

<DockedTimePicker bind:value={twelve} hour12 label="12 hour" />
<DockedTimePicker bind:value={twentyFour} hour12={false} label="24 hour" />

Minute step

minuteStep sets the minute precision. The default is every minute. A tap on the minute ring always lands on a multiple of five, because those are the numbers shown. Dragging uses the full step, so a step of one is reachable by dragging. Arrow keys move by one step.

<script lang="ts">
	import { DockedTimePicker } from 'noph-ui'

	let quarter = $state<string | undefined>('09:15')
	let exact = $state<string | undefined>('09:07')
</script>

<DockedTimePicker bind:value={quarter} minuteStep={15} label="Every 15 minutes" />
<DockedTimePicker bind:value={exact} minuteStep={1} label="Every minute" />

Bounding the selection

min and max take an HH:mm string and limit the range at both ends. An hour with no reachable minute is greyed out on the dial. If AM or PM has no reachable hour, that side of the period selector is disabled. A pick on the dial outside the range moves to the nearest end. A typed time outside the range leaves the value empty and marks the field invalid.

isTimeEnabled is called with minutes since midnight and can block single times, for rules a range cannot express.

<script lang="ts">
	import { DockedTimePicker } from 'noph-ui'

	let value = $state<string | undefined>('10:00')
	let onTheHalfHour = $state<string | undefined>('10:00')
</script>

<DockedTimePicker bind:value min="09:00" max="17:00" label="Opening hours" />
<DockedTimePicker
	bind:value={onTheHalfHour}
	isTimeEnabled={(minutes) => minutes % 30 === 0}
	label="On the half hour"
/>

Layout

layout is 'auto' by default. The dialog stacks the fields above the dial, and switches to the wide layout in a short landscape window, where a 256dp dial under a row of fields would not fit. 'vertical' and 'horizontal' fix the layout. The horizontal layout puts the fields and a 216 by 38dp period selector next to the dial instead of above it.

Value: 07:00

<script lang="ts">
	import { Button, TimePickerDialog } from 'noph-ui'

	let value = $state<string | undefined>('07:00')
	let vertical = $state(false)
	let horizontal = $state(false)
</script>

<Button variant="outlined" onclick={() => (vertical = true)}>Vertical</Button>
<Button variant="outlined" onclick={() => (horizontal = true)}>Horizontal</Button>

<TimePickerDialog bind:value bind:open={vertical} layout="vertical" hour12 />
<TimePickerDialog bind:value bind:open={horizontal} layout="horizontal" hour12 />

<p>Value: <code>{value ?? 'undefined'}</code></p>

Localisation

locale takes a BCP 47 tag and sets the clock, the field order, the dial digits and the day-period names. Typed text is parsed with the same locale, using the field order the locale reports. Before hydration the field falls back to HH:mm, so server and client render the same markup.

The two number fields in input mode always use plain digits, because a locale's own numerals do not round trip through a number keyboard. Every label is a prop, so a translated app can replace all of them.

<script lang="ts">
	import { DockedTimePicker } from 'noph-ui'

	let us = $state<string | undefined>('14:30')
	let de = $state<string | undefined>('14:30')
	let ja = $state<string | undefined>('14:30')
</script>

<DockedTimePicker bind:value={us} locale="en-US" label="en-US" />
<DockedTimePicker bind:value={de} locale="de-DE" label="de-DE" />
<DockedTimePicker bind:value={ja} locale="ja-JP" label="ja-JP" />

The dial on its own

ClockDial is exported for layouts the pickers do not cover. It is fully controlled: pass value as minutes since midnight and the selection it edits, and it reports every change through onselect. onselectionend fires when a gesture ends and says whether a pointer or the keyboard made it. The pickers use this to decide whether to move on from the hour to the minute.

Editing the hour of 02:30 PM

<script lang="ts">
	import { ClockDial, formatMinutes } from 'noph-ui'

	let value = $state(14 * 60 + 30)
	let selection = $state<'hour' | 'minute'>('hour')
</script>

<div class="dial">
	<ClockDial
		{value}
		{selection}
		hour12
		onselect={(next) => (value = next)}
		onselectionend={(source) => {
			if (source === 'pointer' && selection === 'hour') selection = 'minute'
		}}
	/>
	<p>
		Editing the <code>{selection}</code> of
		<code>{formatMinutes(value, 'en-US', true)}</code>
	</p>
	<button type="button" onclick={() => (selection = selection === 'hour' ? 'minute' : 'hour')}>
		Switch field
	</button>
</div>

<style>
	.dial {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 0.5rem;
	}
</style>

Forms and validation

Passing a name submits the HH:mm value with the form through a hidden input. Validation stays on the visible field, so a blocked submit points to a field the browser can focus. The timing matches the date picker: feedback appears on submit or blur, not while a time is being typed. issues replaces the supporting text with your own messages, and a form reset() returns the field to defaultValue.

<script lang="ts">
	import { Button, DockedTimePicker } from 'noph-ui'
	import type { Issue } from 'noph-ui/types'

	let value = $state<string | undefined>()
	let issues = $state<Issue[]>([])
	let submitted = $state('')

	const handleSubmit = (event: SubmitEvent) => {
		event.preventDefault()
		const data = new FormData(event.currentTarget as HTMLFormElement)
		const next = data.get('pickupTime')
		issues = next ? [] : [{ message: 'Pick a pickup time.' }]
		submitted = next ? `pickupTime=${next}` : ''
	}
</script>

<form onsubmit={handleSubmit} novalidate>
	<DockedTimePicker bind:value name="pickupTime" {issues} required label="Pickup time" />
	<Button type="submit" variant="filled">Submit</Button>
</form>

{#if submitted}
	<p>Submitted <code>{submitted}</code></p>
{/if}

<style>
	form {
		display: flex;
		align-items: flex-start;
		gap: 1rem;
	}
</style>

Opening it yourself

Both pickers export show() and close(), reachable through bind:this. open is bindable in both directions, so it updates when the panel closes by Esc or a click outside.

Value: 12:00

<script lang="ts">
	import { Button, TimePickerDialog } from 'noph-ui'

	let value = $state<string | undefined>('12:00')
	let picker = $state<ReturnType<typeof TimePickerDialog>>()
</script>

<Button variant="filled" onclick={() => picker?.show()}>Show</Button>

<TimePickerDialog bind:this={picker} bind:value hour12 />

<p>Value: <code>{value ?? 'undefined'}</code></p>

Reacting to a change

onchange reports every pending change while the panel is open, so a page can preview a time before it is confirmed. onconfirm fires once, on OK, with the committed value. oncancel fires when the panel is dismissed.

<script lang="ts">
	import { Button, TimePickerDialog } from 'noph-ui'

	let value = $state<string | undefined>('07:00')
	let open = $state(false)
	let log = $state<string[]>([])

	const note = (line: string) => {
		log = [line, ...log].slice(0, 5)
	}
</script>

<Button variant="filled" onclick={() => (open = true)}>Select time</Button>
<TimePickerDialog
	bind:value
	bind:open
	hour12
	onchange={(next) => note(`changed to ${next}`)}
	onconfirm={(next) => note(`confirmed ${next}`)}
	oncancel={() => note('cancelled')}
/>

<ul>
	{#each log as line, index (index)}
		<li>{line}</li>
	{/each}
</ul>

Theming

Colours and shapes come from the theme. Each part also has a custom property for cases the theme does not cover. Set them on the picker. They inherit into the dial, the fields and the period selector. The docked variant's text field also takes every --np-text-field-* token.

PropertyDefault
--np-time-picker-headline-color--np-color-on-surface-variant
--np-time-picker-time-selector-container-shape--np-shape-corner-small
--np-time-picker-period-selector-height5rem (80dp), 4.5rem (72dp) in input mode
--np-time-picker-time-selector-container-width6rem (96dp), 7.125rem (114dp) with no period selector
--np-time-picker-time-selector-selected-container-color--np-color-primary-container
--np-time-picker-time-selector-selected-label-color--np-color-on-primary-container
--np-time-picker-time-selector-unselected-container-color--np-color-surface-container-highest
--np-time-picker-time-selector-unselected-label-color--np-color-on-surface
--np-time-picker-time-selector-separator-color--np-color-on-surface
--np-time-picker-period-selector-container-shape--np-shape-corner-small
--np-time-picker-period-selector-outline-color--np-color-outline
--np-time-picker-period-selector-selected-container-color--np-color-tertiary-container
--np-time-picker-period-selector-selected-label-color--np-color-on-tertiary-container
--np-time-picker-period-selector-unselected-label-color--np-color-on-surface
--np-time-picker-clock-dial-container-color--np-color-surface-container-highest
--np-time-picker-clock-dial-container-shape--np-shape-corner-full
--np-time-picker-clock-dial-size16rem (256dp)
--np-time-picker-clock-dial-label-color--np-color-on-surface
--np-time-picker-clock-dial-selected-label-color--np-color-on-primary
--np-time-picker-clock-dial-selector-color--np-color-primary, the handle, the track and the centre dot
--np-docked-time-picker-container-color--np-color-surface-container-high
--np-docked-time-picker-container-shape--np-shape-corner-large

Example

Value: 07:00

<script lang="ts">
	import { Button, TimePickerDialog } from 'noph-ui'

	let value = $state<string | undefined>('07:00')
	let open = $state(false)
</script>

<Button variant="filled" onclick={() => (open = true)}>Select time</Button>
<TimePickerDialog
	bind:value
	bind:open
	hour12
	--np-time-picker-clock-dial-container-color="var(--np-color-tertiary-container)"
	--np-time-picker-clock-dial-selector-color="var(--np-color-tertiary)"
	--np-time-picker-clock-dial-selected-label-color="var(--np-color-on-tertiary)"
	--np-time-picker-time-selector-selected-container-color="var(--np-color-tertiary-container)"
	--np-time-picker-time-selector-selected-label-color="var(--np-color-on-tertiary-container)"
	--np-time-picker-clock-dial-size="14rem"
/>

<p>Value: <code>{value ?? 'undefined'}</code></p>

Motion and gestures

A drag can start anywhere on the dial and continue past its edge. A press counts as a drag only after it moves 3px, so a tap is not read as a tiny drag. While a finger is down, the handle has no transitions and follows the finger exactly. Lifting the finger outside the dial still ends the gesture.

Between taps the handle animates to its new angle the shorter way round, so 11 to 12 turns 30 degrees forwards, not 330 backwards. On the 24 hour dial, changing ring also animates the handle's distance from the centre.

What movesHowToken
Dial handle and trackRotates and reaches to the new angle and ring--np-motion-expressive-default-spatial
Dial numbersCross-fade as the selected one changes--np-motion-expressive-fast-effects
Hour and minute fieldsCross-fade the selected container and label--np-motion-expressive-default-effects
Modal and scrimFade in and out with the dialog--np-motion-expressive-slow-effects

All transitions only run under prefers-reduced-motion: no-preference. With reduced motion, the handle jumps straight to its new angle. Under forced-colors: active the dial uses explicit colours.

Accessibility

The dial is a role="listbox". Its accessible name says which field it edits. Every reachable time is a role="option" button inside it, with one roving tab stop on the current value. Only every fifth minute shows a number. The other minutes are unlabelled options at the same positions, so the keyboard reaches every minute the step allows and the face stays readable. An option outside min and max has aria-disabled, so screen readers still read it instead of skipping it. The pending time is announced through a polite live region when a gesture ends, not on every degree of a drag.

The dial reads the pointer position, not the numbers, so the numbers are not pointer targets. This makes the input mode the path for anyone not using a pointer, which is why the toggle is on by default. Think twice before turning it off with modeToggle.

KeyMoves
→ ↑One step clockwise, wrapping at the top
← ↓One step anticlockwise, wrapping at the top
PgUp PgDnFive steps at a time
Home EndFirst or last value of the ring
Enter SpaceSelect the focused value
TabOut of the dial, on to the fields and the buttons
EscClose the panel

The hour and minute fields are a role="radiogroup" of two radios, because they choose which field the dial edits. Each is named with its label and its current value. The period selector is a radiogroup too. In input mode the two fields are text inputs with inputmode="numeric". A complete hour moves focus to the minute. An hour the clock cannot hold is reported through setCustomValidity instead of being dropped.

Focus moves into the dial when a panel opens and back to the text field when the docked one closes.

API

Methods

MethodTypeDescription
show()() => voidOpens the panel. Does nothing while it is already open, disabled or read only.
close()() => voidCloses the panel without committing the pending time.

Shared props

Both DockedTimePicker and TimePickerDialog take these.

AttributeTypeDefaultDescription
min / maxstring—Earliest and latest selectable time, as HH:mm.
minuteStepnumber1Minute precision. A tap still lands on a multiple of five.
hour12booleanfrom localeForces a 12 or 24 hour clock, and with it the period selector.
localestringthe browser'sBCP 47 tag governing the clock, the digits and the day-period names.
isTimeEnabled(minutes: number) => boolean—Called with minutes since midnight. Return false to block a time.
modeTogglebooleantrueShows the button that swaps the dial for the typed fields.
issues{ message: string }[]undefinedError messages shown instead of the supporting text. Pass a remote form field's issues().
name / formstring—Submits the HH:mm value with a form through a hidden input.
cancelLabel / confirmLabelstring'Cancel' / 'OK'The two buttons along the bottom.
hourLabel / minuteLabel / dayPeriodLabelstring'Hour' / 'Minute' / 'AM or PM'Accessible names of the fields and the period selector.
amLabel / pmLabelstringfrom localeText of the two period options.
selectHourLabel / selectMinuteLabelstring'Select hour' / 'Select minute'Accessible name of the dial, one for each field it edits.
hourOptionLabel / minuteOptionLabel(value: string, total: number) => string'3 hours of 12'Accessible name of one number on the dial.
dialModeLabel / inputModeLabelstring'Switch to dial mode' / 'Switch to text input mode'Accessible name of the mode toggle, named after the mode it switches to.
invalidTimeMessagestring'Enter a valid time.'Validity message for text the picker cannot read as a time.
onchange(value: string | undefined) => void—Fires on every pending change while the panel is open.

DockedTimePicker

The shared props above, plus the text field's own. label defaults to 'Time' and openPickerLabel to 'Show time picker'.

AttributeTypeDefaultDescription
variant'outlined' | 'filled''outlined'Text field variant.
label / supportingTextstring'Time' / the locale's patternField label, and the hint under it.
defaultValuestring | number | null—Value a form reset() returns to.
required / disabled / readonly / noAsteriskbooleanfalsePassed to the text field. Disabled and read only fields do not open.
openPickerLabelstring'Show time picker'Accessible name of the trailing icon button.
startSnippet | undefinedundefinedLeading icon, passed to the text field.

Bindables

AttributeTypeDescription
valuestring | number | null | undefinedSelected time as HH:mm. A number is read as minutes since midnight and normalised on change.
openbooleanWhether the docked panel is showing.
mode'dial' | 'input'Which mode is on screen.
elementHTMLSpanElementThe picker's root element, the text field.

TimePickerDialog

The shared props above, plus the modal's own.

AttributeTypeDefaultDescription
layout'auto' | 'vertical' | 'horizontal''auto''auto' turns horizontal in a short landscape window.
title / inputTitlestring'Select time' / 'Enter time'Headline, one per mode.
onconfirm(value: string | undefined) => void—Fires on OK with the committed value.
oncancel() => void—Fires when the panel is dismissed. The value does not change.

Bindables

AttributeTypeDescription
valuestring | number | null | undefinedSelected time as HH:mm, written on OK.
openbooleanWhether the modal is showing.
mode'dial' | 'input'Which mode is on screen.
elementHTMLDialogElementThe underlying dialog.

ClockDial

The dial alone, fully controlled. It holds no state, so the caller decides what a change means.

AttributeTypeDefaultDescription
valuenumberrequiredMinutes since midnight.
selection'hour' | 'minute''hour'Which field the dial is editing, and so which ring it shows.
min / maxnumber—Minutes since midnight. The pickers take an HH:mm string instead.
hour12booleanfalseOne ring of twelve hours instead of two rings of twenty four.
onselect(minutes: number) => void—Every change, including each step of a drag.
onselectionend(source: 'pointer' | 'keyboard') => void—Fires when a gesture ends, with its source. Use it to move on to the minute.
elementHTMLDivElement—Bindable root element of the dial.

Time helpers

The maths behind the picker is exported too, so an app can use the same handling. The Date based versions are documented with the date and time picker.

FunctionDescription
parseISOTime(value)Minutes since midnight from an HH:mm string, or from a number that already is minutes. undefined for anything unusable.
toISOTime(minutes) / formatMinutes(minutes, locale, hour12)Formats minutes as HH:mm, or as a localised time.
parseTimeInput(text, locale, hour12) / getTimePattern(locale, hour12)Parses typed text in the locale's field order, and returns the hint for that format.
clampMinutes(minutes, min, max) / isMinuteWithinPulls a time back into a range, or reports whether it is already inside one.
snapToStep(minutes, step)Rounds the minute to the nearest step without ever rolling into the next hour.