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.
| 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-period-selector-height | 5rem (80dp), 4.5rem (72dp) in input mode |
--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 |
--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
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 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 |
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.
| 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, 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
| 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 | Minute precision. A tap still lands on a multiple of five. |
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 block a time. |
modeToggle | boolean | true | Shows the button that swaps the dial for the typed fields. |
issues | { message: string }[] | undefined | Error messages shown instead of the supporting text. Pass a remote form field's issues(). |
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, 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 / inputModeLabel | string | 'Switch to dial mode' / 'Switch to text input mode' | Accessible name of the mode toggle, named after the mode it switches to. |
invalidTimeMessage | string | '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'.
| 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. |
start | Snippet | undefined | undefined | Leading icon, passed to the text field. |
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 mode is on screen. |
element | HTMLSpanElement | The picker's root element, the text field. |
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 | — | Fires on OK with the committed value. |
oncancel | () => void | — | Fires when the panel is dismissed. The value does not change. |
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 mode is on screen. |
element | HTMLDialogElement | The underlying dialog. |
ClockDial
The dial alone, fully controlled. It holds no state, so the 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. The pickers take an HH:mm string instead. |
hour12 | boolean | false | One 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. |
element | HTMLDivElement | — | 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.
| 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) | 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) / 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. |