Time pickers

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

value is an HH:mm string on a 24 hour clock, whatever clock is on screen, so '20:00' and '08:00 PM' are the same value. For a day as well as a time use the date and time picker; for a day on its own, the date picker.

Usage

value is bindable and stays in sync with typing and with the dial. Picking in the panel is provisional until OK confirms it, and Cancel discards it and leaves the previous value alone.

The text field takes a typed time in the locale's own shape, and the supporting text shows that shape as a hint. On a 12 hour clock the day period has to be typed too, since 07:30 alone could mean either half of the day.

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 carry a toggle in the bottom left that swaps the dial for two number fields, and mode is bindable so a page can choose which one opens. The dial is the faster way to reach a rough time on a touch screen; the input mode is the faster way to enter an exact one, and it is the mode that works without a pointer. Set modeToggle to false to commit to one of them.

Tapping the hour ring moves on to the minute by itself, the way the dial is meant to flow. A keyboard user stays on the hour, so an hour can still be adjusted after it is first chosen.

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

Left to itself the clock follows the locale. hour12 overrides it, and with it whether there is an AM/PM selector at all.

The 24 hour dial carries two rings: 00 to 11 on the outside and 12 to 23 on the inside. How far your finger is from the centre chooses the ring, so the whole day is one gesture away. With no period selector to sit beside them 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 how finely the minute can be cut, every minute by default. A tap on the minute ring always lands on a whole five minutes, because that is what the numbers read; dragging keeps the full step, so a step of one is still reachable by dragging to it. 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 bound the range at both ends. An hour with no reachable minute left in it is greyed out on the dial, a half of the day with no reachable hour left disables that side of the period selector, and a pick outside the range is pulled back to the nearest end rather than taken. A typed time outside it is refused the same way.

isTimeEnabled is called with minutes since midnight and takes individual times out of reach, 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 turns to the wide arrangement in a short landscape window, where a 256dp dial under a row of fields would not fit. 'vertical' and 'horizontal' pin it. The horizontal layout puts the fields and a 216 by 38dp period selector beside the dial rather than 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 governs the clock, the order of the fields, the digits of the dial and the day-period names. Typed text is read back through the same locale, by the field order the locale itself reports rather than a pattern per language. Before hydration the field falls back to HH:mm, so the server and the client agree on the markup.

The two number fields of the input mode stay on 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 a layout neither picker covers. It is fully controlled: give it value as minutes since midnight and the selection it is editing, and it reports every change back through onselect. onselectionend fires when a gesture finishes and says whether a pointer or the keyboard did it, which is how the pickers decide whether to move the turn 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 surrounding form through a hidden input, while validation stays on the visible field, so a blocked submit reports against a control the browser can focus. The timing matches the date picker: feedback appears on submit or blur, never while a time is still 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'

	let value = $state<string | undefined>()
	let issues = $state<{ message: string }[]>([])
	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, and open is bindable in both directions so it follows a close by Esc or by clicking away.

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>
<Button variant="outlined" onclick={() => picker?.close()}>Close</Button>

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

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

Reacting to a change

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

<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, and every part exposes a custom property for the cases the theme cannot reach. Set them on the picker itself; 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-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-variant
--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

The dial reads the pointer itself rather than relying on the numbers as hit targets, so a drag can begin anywhere on it and continue past its edge. A press is only a drag once it has travelled 3px, which keeps a tap from being read as a tiny drag. While a finger is down every transition on the handle is switched off so it follows exactly, and the pointer is captured so lifting outside the dial still ends the gesture.

Between taps the handle animates to its new angle by the shorter way round, so 11 to 12 turns 30 degrees forwards rather than 330 backwards. Changing ring on the 24 hour dial animates the handle's distance from the centre at the same time.

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

Every transition sits inside prefers-reduced-motion: no-preference, so the handle jumps straight to its new angle when motion is turned down, and the dial is drawn with explicit colours under forced-colors: active.

Accessibility

The dial is a role="listbox" whose accessible name says which field it is editing, and every reachable time is a role="option" button inside it, with one roving tab stop on the current value. Only every fifth minute carries a number; the rest are unlabelled options at the same positions, so a keyboard reaches every minute the step allows even though the face stays readable. An option outside min and max carries aria-disabled so it stays readable rather than being skipped, and the pending time is announced through a polite live region once a gesture ends rather than on every degree of a drag.

Because the dial reads the pointer rather than the numbers, the numbers themselves are not pointer targets. That makes the input mode the path for anyone not using a pointer, which is why the toggle is on by default. Turning it off with modeToggle is worth a second thought.

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, since they choose which field the dial edits, and each is named with its label and its current value. The period selector is a radiogroup too. In the input mode the two fields are text inputs with inputmode="numeric"; a whole hour moves focus on to the minute, and an hour the clock cannot hold reports through setCustomValidity rather than being silently 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 / maxstringEarliest and latest selectable time, as HH:mm.
minuteStepnumber1How finely the minute can be cut. A tap still lands on a whole five minutes.
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) => booleanCalled with minutes since midnight. Return false to take a time out of reach.
modeTogglebooleantrueShows the button that swaps the dial for the typed fields.
issues{ message: string }[]Validation messages, rendered as a role="alert".
name / formstringSubmits 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, by the field it is editing.
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, by the mode it would move to.
invalidTimeMessagestring'Enter a valid time.'Validity message for text the picker cannot read as a time.
onchange(value: string | undefined) => voidEvery provisional 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 | nullValue 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.

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 half of the picker is on screen.
elementHTMLSpanElementThe picker's root element, which is the text field itself.

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) => voidThe value that was committed, on OK.
oncancel() => voidThe panel was dismissed and the value left alone.

Bindables

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

ClockDial

The dial on its own, fully controlled. It holds no state, so a 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 / maxnumberMinutes since midnight, unlike the pickers which take an HH:mm string.
hour12booleanfalseOne ring of twelve hours, rather than two rings of twenty four.
onselect(minutes: number) => voidEvery change, including each step of a drag.
onselectionend(source: 'pointer' | 'keyboard') => voidA gesture finished, and by which means. Use it to move the turn on to the minute.
elementHTMLDivElementBindable root element of the dial.

Time helpers

The maths behind the picker is exported too, so an app can share the same handling. The Date based counterparts live 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)The value as HH:mm, and as a time of a locale.
parseTimeInput(text, locale, hour12) / getTimePattern(locale, hour12)Typed text read by the locale's own field order, and the hint that describes it.
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.