Time picker
Time picker is a time field entered across segmented numeric hour and minute inputs, plus a toggle AM/PM segment at a 12-hour format, matching the date picker's own segmented-field pattern, with a trailing clock icon that opens a panel to pick the value instead. Two panel styles are available: the default is a panel of hour, minute, and period columns, which falls back to a mobile-friendly modal below a small viewport, showing either the same arrow-button columns as desktop or a scrollable, swipeable wheel per column. The alternative is a single scrollable list of every time of day at a set interval, where picking an entry both commits it and closes the panel immediately, exactly like a select field. That list style renders identically on every viewport, and never falls back to the mobile modal.
Dependencies
When to use
Use time picker whenever someone needs to pick a time of day, such as a
meeting slot, a delivery window, an alarm, or an opening hour. Typing goes
through independent hour and minute (and period) segments rather than a
single free-text field, exactly like
The picked time is stored as a full point in time, not just an hour and a minute on their own, exactly like date picker. That's what makes its timezone setting meaningful: it names the zone the hour and minute are read and written in, defaulting to the viewer's own when unset. See "Timezones" further down for the full contract.
Also see date picker's own combined date-and-time mode, which embeds this same hour, minute, and period panel under its own calendar grid for a single field covering both.
Install & usage
npm install @eunomia/elements
import "@eunomia/elements/time-picker.js";
<eun-time-picker label="Meeting time" name="meeting-time"></eun-time-picker>
<script>
document
.querySelector("eun-time-picker")
.addEventListener("eunchange", (event) => {
console.log(event.target.value); // a Date, or null
});
</script>
Importing the file registers <eun-time-picker> as a custom element, along
with eun-icon/eun-modal/eun-label it depends on internally (see the
dependencies listed above). Import those too if you're registering
components individually rather than through a barrel that already does it.
value is entirely yours to read from eunchange, and the component never
submits anywhere on its own beyond native form participation (name +
value, exactly like eun-input).
Guidance
- Let
localedrive the hour format (12h/24h) and segment order rather than hardcoding one:en-USandfr-FRgenuinely disagree on both, and some locales (e.g.zh-CN) even put the AM/PM segment before the hour - Set
timezoneexplicitly whenever the picked time means something in a zone other than the viewer's own (e.g. "the meeting is 14:00 New York time, whoever's looking at this"), and never re-derive it from the viewer's clock - Reach for
step-minute(e.g.15) instead of validating the minute yourself after the fact whenever only certain minute values make sense (a booking slot, a delivery window)
- Reading
valueas anything but aDateornull: parse/format it yourself at the boundary (an API call, a display string) rather than expecting a pre-formatted string back - Assuming an unset
timezonemeans UTC: it means the viewer's own local zone, matching a plainDate's usual behavior, so settimezoneexplicitly whenever you actually mean UTC or another specific zone - Mixing
timezone-qualified and unqualifiedeun-time-pickers on the same page for what's conceptually the same value: pick one zone (or the viewer's own) and stay consistent, or the same instant will visibly read differently side by side
Live testing
Properties
TimePicker <eun-time-picker>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| hour-format | 12 | 24 | — | Overrides the hour format. Auto-detected from locale when unset |
| step-minute | number | 1 | The minute column's increment step, and which minute values the mobile wheel enumerates |
| min-time | string | — | The earliest selectable time of day, as an HH:mm string. A value outside minTime or maxTime reports as invalid rather than being blocked |
| max-time | string | — | The latest selectable time of day, as an HH:mm string. When earlier than minTime, the valid range wraps past midnight instead of being empty |
| mobile-mode | 'wheel' | 'modal' | 'wheel' | Which content the mobile modal fallback shows: the same arrow-button columns as desktop, or a scroll-snap wheel per column. Only relevant when panelMode is columns |
| panel-mode | 'columns' | 'list' | 'columns' | Which panel the trailing clock trigger opens: the hour, minute, and period spin button columns, or a single scrollable listbox of every time of day at a listStepMinutes gap. Picking an entry from the list both commits it and closes the panel immediately. The list renders identically on every viewport, and never switches to the mobile modal fallback that columns uses on a small viewport |
| list-step-minutes | number | 30 | When panelMode is list, the gap in minutes between consecutive listbox entries, such as 15 for quarter-hour granularity or 60 for hourly |
| apply-label | string | 'Apply' | The label of the mobile modal's confirm button |
| cancel-label | string | 'Cancel' | The label of the mobile modal's cancel button |
| aria-label-clock | string | 'Open clock' | The accessible label of the trailing clock button |
| hour-placeholder | string | 'hh' | The placeholder shown in the empty hour segment |
| minute-placeholder | string | 'mm' | The placeholder shown in the empty minute segment |
| hide-timezone-label | boolean | false | Hides the small offset label normally shown next to the field whenever timezone differs from the viewer's own |
| no-animation | boolean | false | Disables the popover's open and close transition and the mobile modal's slide-in, both set explicitly and whenever the browser prefers reduced motion |
| timezone | string | the runtime's own | The time zone the hour and minute are displayed and edited in. Defaults to the runtime's own |
| default-value | Date | null | — | The default value applied when the field connects, and restored on form reset |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| checkLocalValidators | — | Reads local validation state : a partially/invalidly typed segment group reports as invalid, matching native constraint validation's `badInput` — mirrors `eun-date-picker`'s identical contract. Once a value is committed, also checks it against minTime/ maxTime, reporting `rangeUnderflow`/`rangeOverflow` exactly like a native `<input type="time" min max>` would. | |
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| value | Date | null | — | The picked instant |
| locale | string | — | The locale used for the hour format auto-detection, segment order, and the modal's time preview. Defaults to the runtime's own |
| label | string | — | The title label of the field, rendered through the label component |
| instructions | string | — | Instructions displayed below the label |
| name | string | — | The field name |
| disabled | boolean | false | Whether the field is disabled |
| readonly | boolean | false | Whether the field is read only |
| required | boolean | false | Whether the field is required |
| hideError | boolean | false | Whether the errors are hidden |
| validators | Array<Validators<Date | null>> | — | The list of custom validation rules applied to the value |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired whenever the committed value changes: immediately on completing the typed segments, immediately on every column adjustment in the desktop popover, or on the mobile modal's Apply |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --field-border-color | Sets the border color of the field. The same variable the input, select, and date picker use |
| --field-border-color-hover | Sets the border color on hover |
| --field-border-color-focus | Sets the border color on focus |
| --field-background | Sets the background color of the field |
| --field-text-color | Sets the text color of the field |
| --field-placeholder-color | Sets the color of the placeholder for empty segments |
| --field-icon-color | Sets the color of the trailing clock icon |
| --field-error-color | Sets the color of the error message |
| --field-hint-color | Sets the color of the hint message and timezone label |
| --field-focus-outline-color | Sets the focus outline color used throughout the field and panel |
| --time-picker-hover-background | Sets the background color of hovered column arrow buttons and list items |
| --time-picker-selected-background | Sets the background color of the selected item in the list panel mode |
| --time-picker-selected-color | Sets the text color of the selected item in the list panel mode |
| --time-picker-panel-background | Sets the background color of the popover panel |
| --time-picker-panel-transition-duration | Sets the duration of the popover panel's open and close transition |
Locale driven
Leaving hour-format unset auto-detects 12h vs. 24h from locale (or the
viewer's own runtime locale). An en-US field renders hour/minute/period,
an fr-FR field renders just hour/minute.
<eun-time-picker
locale="en-US"
label="12-hour (en-US)"
name="format-12"
></eun-time-picker>
<eun-time-picker
locale="fr-FR"
label="24-hour (fr-FR)"
name="format-24"
></eun-time-picker>
Set hour-format="12"/"24" explicitly to override the locale-driven
default.
Minute step
step-minute controls the minute column's increment (both the arrow
buttons and the mobile wheel). 15 only ever lands on :00/:15/:30/
:45.
<eun-time-picker
step-minute="15"
label="Booking slot"
name="booking-slot"
></eun-time-picker>
Min and max time
min-time/max-time bound the selectable time of day, as "HH:mm"
strings, matching a native <input type="time" min max>. A value outside
the bounds isn't blocked from being typed or picked. It reports as invalid
(rangeUnderflow/rangeOverflow) exactly like that native input does,
with required's own error styling.
<eun-time-picker
min-time="09:00"
max-time="18:00"
label="Office hours"
name="office-hours"
></eun-time-picker>
When min-time is set after max-time (e.g. min-time="22:00"
max-time="06:00"), the valid range wraps past midnight instead of being
empty: anything at or after min-time, or at or before max-time, is
valid.
<eun-time-picker
min-time="22:00"
max-time="06:00"
label="Overnight shift"
name="overnight-shift"
></eun-time-picker>
Mobile layout: wheel or modal
Below a 768px viewport, the panel opens inside a eun-modal. The default
mobile-mode="wheel" renders each column as a scrollable, scroll-snap
wheel (flick or drag to spin it, tap an item, or use the arrow
buttons/keyboard, all three stay equivalent). mobile-mode="modal"
instead reuses the exact same arrow-button columns as desktop.
The rest of this page's demos run at your actual browser width, so they'll only show this if you resize your browser below 768px. The previews below are instead pinned to real 375px-wide iframes, so they always render the mobile layout live, right here. Tap the clock icon to open either.
<eun-time-picker
label="Wheel (default)"
name="mobile-wheel-demo"
></eun-time-picker>
<eun-time-picker
mobile-mode="modal"
label="Modal (arrow buttons)"
name="mobile-modal-demo"
></eun-time-picker>
List panel
panel-mode="list" swaps the hour/minute/[period] columns for a single
scrollable listbox of every time of day, spaced list-step-minutes apart
(default 30), anywhere from minute granularity (1) up through hourly or
coarser (60, 120, …). Picking an entry both commits it and closes the
panel immediately, exactly like eun-select's own options list, so
there's no Apply/Cancel step. Unlike "columns", "list" renders the same
listbox on every viewport. It never falls back to the mobile eun-modal.
<eun-time-picker
panel-mode="list"
list-step-minutes="30"
locale="en-US"
label="Booking slot (12h)"
name="list-panel-12h"
></eun-time-picker>
<eun-time-picker
panel-mode="list"
list-step-minutes="30"
locale="fr-FR"
label="Booking slot (24h)"
name="list-panel-24h"
></eun-time-picker>
At a 12-hour format, every entry's AM/PM is resolved by Intl.DateTimeFormat
off the entry's own hour/minute. Midnight always reads "12:00 AM" and noon
"12:00 PM", not "0:00 AM"/"0:00 PM".
Timezones
value is always an absolute instant (Date). What varies with
timezone is which wall-clock hour/minute that instant is displayed and
edited as. Left unset, timezone defaults to the viewer's own runtime
zone, so an untouched eun-time-picker behaves exactly like a plain
Date's getHours()/getMinutes() always have.
Example 1: no timezone (local behavior). Nothing special: the
field reads/writes whatever the viewer's own device clock says.
<eun-time-picker label="Reminder time" name="reminder"></eun-time-picker>
Example 2: an explicit zone. Set timezone and assign value a known
instant. The field displays that instant's wall-clock hour/minute in that
zone, regardless of where the viewer actually is, plus a small offset
label (e.g. "GMT-5") next to the field so it's never ambiguous which zone
is in play.
<eun-time-picker
id="tz-demo"
timezone="America/New_York"
label="Call time (New York)"
name="call-time"
></eun-time-picker>
<script type="module">
customElements.whenDefined("eun-time-picker").then(() => {
document.querySelector("#tz-demo").value = new Date(
"2026-01-15T19:00:00.000Z",
);
// Displays 14:00 (America/New_York is UTC-5 in January) — not 19:00.
});
</script>
Example 3: the same value, two different timezones. A given
instant reads as a different wall-clock time depending on which zone you
ask, including its offset, which itself shifts across the year under
daylight saving:
<eun-time-picker
id="tz-paris"
timezone="Europe/Paris"
label="Paris"
name="tz-paris"
></eun-time-picker>
<eun-time-picker
id="tz-tokyo"
timezone="Asia/Tokyo"
label="Tokyo"
name="tz-tokyo"
></eun-time-picker>
<script type="module">
customElements.whenDefined("eun-time-picker").then(() => {
const instant = new Date(); // the same instant, shared by both fields
document.querySelector("#tz-paris").value = instant;
document.querySelector("#tz-tokyo").value = instant;
});
</script>
How it works. There's no timezone-aware date/time type built into the
platform, and this library has no date/time library dependency, so every
conversion is done by hand in timezone.utils.ts via
Intl.DateTimeFormat, using the same technique timezone libraries built on
Intl use internally: format a known instant in the target zone to read
its current UTC offset, then resolve a wall-clock hour/minute back to an
instant by correcting for that offset (re-checked once, since the
correction can itself cross a daylight-saving transition). This is fully
DST-safe: picking a time on the day clocks change still resolves to the
correct instant. The one known, documented limitation is at the transition
itself: a wall-clock time that never occurs (the "spring forward" gap) or
occurs twice (the "fall back" overlap) resolves to a deterministic,
stable result without a guarantee of which of the two real occurrences
that is, the same pragmatic tradeoff new Date(y, m, d, h, min) already
makes for the runtime's own local zone.
Disabled and readonly
disabled blocks every interaction and greys the field out. readonly
keeps the value focusable/submittable but blocks editing and the clock
trigger.
<eun-time-picker
disabled
label="Disabled"
name="disabled-demo"
default-value="2026-01-15T09:00:00"
></eun-time-picker>
<eun-time-picker
readonly
label="Readonly"
name="readonly-demo"
default-value="2026-01-15T09:00:00"
></eun-time-picker>
Hint, required, and error
hint shows helper text below the field. required (combined with a
native form.reset() or reportValidity()) shows an error in its place
instead, as soon as the field is empty or a typed time is invalid (e.g. a
partially typed hour, or a 12-hour field missing its AM/PM segment).
<eun-time-picker
label="Alarm"
name="hint-demo"
hint="24-hour format"
></eun-time-picker>
<eun-time-picker
label="Meeting time"
name="error-demo"
required
></eun-time-picker>
Set hide-error to suppress the error/hint text
while keeping the invalid styling (the border, the outline, aria-invalid),
useful when a form shows errors in a summary elsewhere instead of inline.
A native form.reset() restores value to
default-value and re-validates from there, exactly like every
other Eunomia field.
Variants
variant matches every other text-like field (eun-input/eun-select/
eun-date-picker): outline (default), fill, underline.
<eun-time-picker
variant="outline"
label="Outline"
name="outline-demo"
></eun-time-picker>
<eun-time-picker variant="fill" label="Fill" name="fill-demo"></eun-time-picker>
<eun-time-picker
variant="underline"
label="Underline"
name="underline-demo"
></eun-time-picker>
Part of the shared vocabulary covered in
Custom
Override the --time-picker-*/--field-* CSS variables, listed in full in
the API tab.
<eun-time-picker
class="custom-time-picker"
label="Custom theme"
name="custom-demo"
></eun-time-picker>
.custom-time-picker {
--field-border-color-focus: #be185d;
--field-focus-outline-color: #be185d;
--time-picker-hover-background: #fce7f3;
}
Keyboard interactions: segmented field
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus between segments and the clock trigger |
0-9 (hour/minute segments) |
Types a digit, auto-advances to the next segment once full |
A / P (period segment) |
Sets AM/PM directly |
ArrowLeft/ArrowRight |
Moves to the previous/next segment |
ArrowUp/ArrowDown |
Increments/decrements the focused segment (hour/minute), or toggles AM/PM |
Backspace (on an empty segment) |
Moves to the previous segment |
Enter / Space (on the trigger) |
Opens the panel |
Keyboard interactions: hour, minute, and period panel
There's no dedicated WAI-ARIA APG composite pattern for a time picker.
Each column is its own role="spinbutton" (the APG building-block
pattern), grouped under a labeled role="group". Both the arrow-button and
wheel visuals share the exact same keyboard contract below.
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus between columns |
ArrowUp/ArrowDown |
Increments/decrements the focused column by 1 step |
PageUp/PageDown |
Increments/decrements the focused column by 5 steps |
Home / End |
Jumps to the column's minimum/maximum value |
Escape |
Closes the popover |
Keyboard interactions: list panel
A standard role="listbox"/role="option" pattern. Each option itself is
focusable via a roving tabindex, rather than eun-select's
combobox-plus-aria-activedescendant one, since this panel's trigger is a
plain icon button, not a text input keeping focus for itself.
| Key | Action |
|---|---|
ArrowUp/ArrowDown |
Moves focus to the previous/next entry |
PageUp/PageDown |
Moves focus 5 entries up/down |
Home / End |
Moves focus to the first/last entry |
Enter / Space |
Picks the focused entry, committing and closing |
Escape |
Closes the popover |
Aria attributes and rules
- Each hour/minute segment is a native
<input inputmode="numeric">with its ownaria-label("Hour"/"Minute"), and the period segment is a<button>labeled "AM/PM", grouped under a singlerole="group" aria-label="…", matchingeun-date-picker's day/month/year segment group. - The clock trigger is
aria-haspopup="dialog"atpanel-mode="columns", oraria-haspopup="listbox"atpanel-mode="list", witharia-expandedtracking the popover/modal's open state, and returns focus to itself on close. - At
panel-mode="columns", each hour/minute/[period] column carriesaria-valuemin/aria-valuemax/aria-valuenow/aria-valuetext, updated live as it's adjusted, and a screen reader announces the new value on every change, whether triggered by the arrow buttons, the keyboard, or (inmobile-mode="wheel") a pointer scroll/tap. The wheel's individual scrollable items arearia-hiddenand excluded from the tab order, since they're a pointer-only affordance layered behind the same accessible spinbutton, not a separate interactive listbox. - At
panel-mode="list", the panel is arole="listbox"ofrole="option"entries, each carryingaria-selected. Opening the panel moves focus directly onto the currently selected entry (or the first, if none matches exactly), scrolled into view. - Below the 768px breakpoint,
panel-mode="columns"renders the panel insideeun-modal(role="dialog", labelled by the field's ownlabelviaaria-label) instead of the popover, with an explicit Apply/Cancel footer, matchingeun-date-picker's identical mobile fallback rationale. Like everyeun-modal, focus isn't currently trapped inside it, so treat that as a known gap rather than an intentional design choice.panel-mode="list"never uses this fallback, and renders the same popover listbox on every viewport. prefers-reduced-motion: reduce(or the explicitno-animationattribute) disables the popover's open/close transition and the mobile wheel's smooth-scroll snapping alike. The wheel still snaps to the nearest item, just instantly rather than with an animated glide.
Reference links