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.
| Property | Default |
|---|---|
--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-width | 6rem (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-size | 16rem (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 moves | How | Token |
|---|---|---|
| Dial handle and track | Rotates and reaches to the new angle and ring | --np-motion-expressive-default-spatial |
| Dial numbers | Cross-fade as the selected one changes | --np-motion-expressive-fast-effects |
| Hour and minute fields | Cross-fade the selected container and label | --np-motion-expressive-default-effects |
| Modal and scrim | Fade 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.
| Key | Moves |
|---|---|
| → ↑ | One step clockwise, wrapping at the top |
| ← ↓ | One step anticlockwise, wrapping at the top |
| PgUp PgDn | Five steps at a time |
| Home End | First or last value of the ring |
| Enter Space | Select the focused value |
| Tab | Out of the dial, on to the fields and the buttons |
| Esc | Close 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
| Method | Type | Description |
|---|---|---|
show() | () => void | Opens the panel. Does nothing while it is already open, disabled or read only. |
close() | () => void | Closes the panel without committing the pending time. |
Shared props
Both DockedTimePicker and TimePickerDialog take these.
| Attribute | Type | Default | Description |
|---|---|---|---|
min / max | string | — | Earliest and latest selectable time, as HH:mm. |
minuteStep | number | 1 | How finely the minute can be cut. A tap still lands on a whole five minutes. |
hour12 | boolean | from locale | Forces a 12 or 24 hour clock, and with it the period selector. |
locale | string | the browser's | BCP 47 tag governing the clock, the digits and the day-period names. |
isTimeEnabled | (minutes: number) => boolean | — | Called with minutes since midnight. Return false to take a time out of reach. |
modeToggle | boolean | true | Shows the button that swaps the dial for the typed fields. |
issues | { message: string }[] | — | Validation messages, rendered as a role="alert". |
name / form | string | — | Submits the HH:mm value with a form through a hidden input. |
cancelLabel / confirmLabel | string | 'Cancel' / 'OK' | The two buttons along the bottom. |
hourLabel / minuteLabel / dayPeriodLabel | string | 'Hour' / 'Minute' / 'AM or PM' | Accessible names of the fields and the period selector. |
amLabel / pmLabel | string | from locale | Text of the two period options. |
selectHourLabel / selectMinuteLabel | string | '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 / inputModeLabel | string | 'Switch to dial mode' / 'Switch to text input mode' | Accessible name of the mode toggle, by the mode it would move to. |
invalidTimeMessage | string | 'Enter a valid time.' | Validity message for text the picker cannot read as a time. |
onchange | (value: string | undefined) => void | — | Every 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'.
| Attribute | Type | Default | Description |
|---|---|---|---|
variant | 'outlined' | 'filled' | 'outlined' | Text field variant. |
label / supportingText | string | 'Time' / the locale's pattern | Field label, and the hint under it. |
defaultValue | string | number | null | — | Value a form reset() returns to. |
required / disabled / readonly / noAsterisk | boolean | false | Passed to the text field. Disabled and read only fields do not open. |
openPickerLabel | string | 'Show time picker' | Accessible name of the trailing icon button. |
Bindables
| Attribute | Type | Description |
|---|---|---|
value | string | number | null | undefined | Selected time as HH:mm. A number is read as minutes since midnight and
normalised on change. |
open | boolean | Whether the docked panel is showing. |
mode | 'dial' | 'input' | Which half of the picker is on screen. |
element | HTMLSpanElement | The picker's root element, which is the text field itself. |
TimePickerDialog
The shared props above, plus the modal's own.
| Attribute | Type | Default | Description |
|---|---|---|---|
layout | 'auto' | 'vertical' | 'horizontal' | 'auto' | 'auto' turns horizontal in a short landscape window. |
title / inputTitle | string | 'Select time' / 'Enter time' | Headline, one per mode. |
onconfirm | (value: string | undefined) => void | — | The value that was committed, on OK. |
oncancel | () => void | — | The panel was dismissed and the value left alone. |
Bindables
| Attribute | Type | Description |
|---|---|---|
value | string | number | null | undefined | Selected time as HH:mm, written on OK. |
open | boolean | Whether the modal is showing. |
mode | 'dial' | 'input' | Which half of the picker is on screen. |
element | HTMLDialogElement | The underlying dialog. |
ClockDial
The dial on its own, fully controlled. It holds no state, so a caller decides what a change means.
| Attribute | Type | Default | Description |
|---|---|---|---|
value | number | required | Minutes since midnight. |
selection | 'hour' | 'minute' | 'hour' | Which field the dial is editing, and so which ring it shows. |
min / max | number | — | Minutes since midnight, unlike the pickers which take an HH:mm string. |
hour12 | boolean | false | One ring of twelve hours, rather than two rings of twenty four. |
onselect | (minutes: number) => void | — | Every change, including each step of a drag. |
onselectionend | (source: 'pointer' | 'keyboard') => void | — | A gesture finished, and by which means. Use it to move the turn on to the minute. |
element | HTMLDivElement | — | Bindable 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.
| Function | Description |
|---|---|
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) / isMinuteWithin | Pulls 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. |