Date picker
Date picker represents a calendar date entered across separate day, month, and year fields, with a trailing icon that opens a calendar for picking the value directly instead of typing it.
Dependencies
When to use
Use a date picker whenever someone needs to pick a calendar date, a month, a year, or a start/end range of any of those, such as a booking form, a date-of-birth field, a report's date range, or a subscription's billing month. It's built on the same segmented-input pattern GOV.UK and USWDS use for accessible date entry: independent numeric fields instead of one free-text field a screen reader has to parse, plus a calendar for anyone who'd rather browse than type. Pick the granularity that matches what's actually being chosen: a day, a month, or a year, and layer a range or a combined time selection on top when the situation calls for it. See the Examples tab for each of these in action.
Install & usage
npm install @eunomia/elements
import "@eunomia/elements/date-picker.js";
<eun-date-picker label="Date of birth" name="dob"></eun-date-picker>
<script>
document
.querySelector("eun-date-picker")
.addEventListener("eunchange", (event) => {
console.log(event.target.value); // a Date, or null
});
</script>
Importing the file registers <eun-date-picker> as a custom element, along
with the components it depends on internally (see the dependency badges
above). Import those too if you're registering components individually
rather than through a barrel that already does it. value (or
rangeValue in mode="range") is entirely yours to read from
eunchange. The component never submits anywhere on its own beyond native
form participation (name + value, exactly like eun-input).
Guidance
- Reach for
granularity="month"/"year"instead of a full day picker whenever the day genuinely doesn't matter (a billing month, a birth year): fewer segments to type, and a calendar that shows the actual choices instead of 30+ mostly-irrelevant day cells - Set
min-date/max-date(oris-date-disabledfor irregular constraints) instead of validating the typed value yourself after the fact: disabled cells are skipped by keyboard navigation automatically - Let
localedrive the segment order and month/weekday labels rather than hardcoding a "dd/mm/yyyy" assumption, sincefr-FRanden-USgenuinely disagree on it
- Replacing the segmented field with a single free-text input bound to the same value: the three-segment layout is the accessible pattern this component exists to provide, not an incidental default
- 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 - Using
granularity="year"/"month"range mode expecting day-level precision at the edges: both ends are normalized to the 1st of the period, so a month range'sendis the 1st of the last included month, not its last day
Live testing
Properties
DatePicker <eun-date-picker>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| with-time | boolean | false | Adds an hour, minute, and period time picker panel below the day grid, and the same segments inline in the field next to day, month, and year, so value carries a picked time of day instead of always landing on midnight. Only relevant with day granularity and single mode |
| hour-format | 12 | 24 | — | Overrides the time picker panel's hour format. Auto-detected from locale when unset. Only relevant when withTime is set |
| step-minute | number | 1 | The time picker panel's minute column increment step. Only relevant when withTime is set |
| min-time | string | — | The earliest selectable time of day, as an HH:mm string, when withTime is set. 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 |
| mode | 'single' | 'range' | 'single' | Whether a single value or a start and end range is picked |
| granularity | 'day' | 'month' | 'year' | 'day' | How precise the picked value is. Controls both the number of segments rendered and which calendar surface opens |
| default-value | Date | null | — | The default value applied when the field connects, and restored on form reset, in single mode |
| week-starts-on | 0 | 1 | 1 | Which weekday a week starts on, Sunday or Monday. Only relevant with day granularity |
| work-week | boolean | false | Whether to drop Saturdays and Sundays entirely from the day grid, as a five-column grid instead of seven. Only relevant with day granularity |
| no-animation | boolean | false | Disables the calendar popover's open and close transition and the mobile modal's slide-in, both set explicitly and whenever the browser prefers reduced motion. The panel just shows or hides instantly, and swiping just jumps to the new period, instead |
| timezone | string | — | The time zone the time picker panel's hour and minute are displayed and edited in, when withTime is set. Defaults to the runtime's own |
| today-label | string | 'Today' | The label of the day grid's jump to today shortcut. Only shown with day granularity |
| 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-calendar | string | 'Open calendar' | The accessible label of the trailing calendar button |
| day-placeholder | string | 'jj' | The placeholder shown in the empty day segment |
| month-placeholder | string | 'mm' | The placeholder shown in the empty month segment |
| year-placeholder | string | 'yyyy' | The placeholder shown in the empty year segment |
| hour-placeholder | string | 'hh' | The placeholder shown in the empty hour segment. Only relevant when withTime is set |
| minute-placeholder | string | 'mm' | The placeholder shown in the empty minute segment. Only relevant when withTime is set |
| default-range-value | EunomiaDateRange | — | The default range applied when the field connects, and restored on form reset, in range mode |
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 |
|---|---|---|---|
| rangeValue | EunomiaDateRange | {start: null, end: null} | The picked range, in range mode |
| minDate | Date | — | The earliest selectable value |
| maxDate | Date | — | The latest selectable value |
| isDateDisabled | (date: Date) => boolean | — | An optional predicate disabling specific days, months, or years on top of minDate and maxDate |
| checkLocalValidators | — | Reads local validation state : a partially/invalidly typed segment group reports as invalid, matching native constraint validation's `badInput`. While withTime is active, also checks the typed time segments the same way, and the committed value's time of day against minTime/maxTime, reporting `rangeUnderflow`/`rangeOverflow` exactly like a native `<input type="time" min max>` would. | |
| buildCommonValidators | — | Overrides `ValidatorsMixin`'s generic "required" check, which only ever reads `value` — always `null` in `mode="range"`, since the range lives on the sibling `rangeValue` property instead (see its own doc). Without this override, a `required` range-mode field would report `valueMissing` forever, even with a fully picked range. An arrow field (not a regular method) to match `ValidatorsMixinInterface`'s property-typed signature — same reason checkLocalValidators above is one, and why this can't delegate to the base implementation via `super` for `mode="single"` either ; it's reimplemented in full instead. | |
| variant | 'outline' | 'fill' | 'underline' | 'outline' | The visual variant to apply to the field |
| value | Date | null | — | The picked value, in single mode. Normalized to the first of the month or January first of the year outside day granularity |
| locale | string | — | The locale used for month and weekday labels and the segments' field order. 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 or range changes, either from completing the segments or from the calendar, immediately on selection 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 and calendar footer divider. The same variable the input, select, and textarea use, so a site that already themes those themes this identically |
| --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 calendar icon |
| --field-error-color | Sets the color of the error message |
| --field-hint-color | Sets the color of the hint message |
| --field-focus-outline-color | Sets the focus outline color used throughout the field and calendar |
| --date-picker-hover-background | Sets the background color of hovered calendar cells and navigation buttons |
| --date-picker-today-color | Sets the accent color marking today, the current month, the current year, and the Today shortcut |
| --date-picker-outside-color | Sets the text color of day grid cells outside the displayed month |
| --date-picker-weekday-color | Sets the text color of the day grid's weekday labels |
| --date-picker-selected-background | Sets the background color of the selected cell, in single mode |
| --date-picker-selected-text-color | Sets the text color on top of date-picker-selected-background |
| --date-picker-range-background | Sets a background tint for every cell in a picked range, start, end, and in between alike, in range mode, one uniform color across the whole span |
| --date-picker-panel-background | Sets the background color of the popover panel |
| --date-picker-panel-transition-duration | Sets the duration of the popover panel's open and close transition, disabled entirely by the no-animation attribute or reduced motion settings |
Single date
Leaving mode/granularity unset picks a single full date: day, month,
and year segments, and a day-grid calendar.
<eun-date-picker label="Appointment date" name="appointment"></eun-date-picker>
Date range
mode="range" renders two segment groups (start/end) and highlights the
span between them in the calendar. Click a first day, then a second. The
earlier of the two becomes the start regardless of click order.
<eun-date-picker mode="range" label="Stay dates" name="stay"></eun-date-picker>
Month granularity
granularity="month" drops the day segment and shows a 12-month grid for
the displayed year instead of a day grid. value lands on the 1st of the
picked month.
<eun-date-picker
granularity="month"
label="Billing month"
name="billing-month"
></eun-date-picker>
Year granularity
granularity="year" renders a single year segment and a paged 12-year
grid. value lands on January 1st of the picked year.
<eun-date-picker
granularity="year"
label="Birth year"
name="birth-year"
></eun-date-picker>
Date and time
with-time adds an hour/minute/[period] panel below the day grid, so
value carries a picked time of day instead of always landing on midnight,
for a single combined date-and-time field, rather than pairing
eun-date-picker with a separate eun-time-picker. Only meaningful at
granularity="day" and mode="single" (silently ignored otherwise, the
same way weekStartsOn/workWeek already are outside granularity="day").
Picking a range's time of day isn't supported yet.
As soon as with-time is set, the desktop popover switches to the same
staging + Apply/Cancel flow the mobile modal already used. Clicking a day
no longer commits and closes it immediately (there's still a time to
adjust below the grid), so an explicit Apply confirms the whole
date-and-time selection at once. The picked hour/minute[/period] is also
rendered as its own typeable segment group directly in the field, right
next to the day/month/year segments, so the time is always visible and
directly editable in the closed field too, not only once the panel is
open. Typing a complete date and a complete time are independent. Type
either one first, and the other keeps whatever it already had (an
already-typed time, an already-committed value's time, or midnight if
neither was ever set).
<eun-date-picker
with-time
label="Appointment"
name="appointment-time"
></eun-date-picker>
timezone, hour-format, and step-minute all pass straight through to
the embedded panel, exactly like the matching properties on
eun-time-picker itself:
<eun-date-picker
with-time
timezone="America/New_York"
label="Call (New York time)"
name="call-datetime"
></eun-date-picker>
value is still an absolute instant (a Date), composed DST-safely from
the picked day and time via the same Intl.DateTimeFormat-based conversion
eun-time-picker uses. See that component's own "Timezones" section for
the full contract, including its documented edge cases around a DST
transition's spring-forward gap/fall-back overlap. min-date/max-date/
is-date-disabled remain date-only even with with-time set: there's no
way to disable a specific half-day.
min-time/max-time (as "HH:mm" strings, matching eun-time-picker's
own properties) bound the selectable time of day instead, independently of
min-date/max-date: a value outside them reports as invalid
(rangeUnderflow/rangeOverflow) rather than being blocked from being
typed or picked:
<eun-date-picker
with-time
min-time="09:00"
max-time="18:00"
label="Appointment (office hours)"
name="appointment-office-hours"
></eun-date-picker>
Limiting selectable dates
min-date/max-date bound the selectable range. is-date-disabled adds
an arbitrary predicate on top (e.g. blocking weekends). Both apply to
whichever granularity is active: a disabled month/year is one whose entire
span falls outside the bounds, or whose 1st-of-period value fails the
predicate.
<eun-date-picker
id="bounded-demo"
label="Delivery date"
name="delivery"
></eun-date-picker>
<script type="module">
customElements.whenDefined("eun-date-picker").then(() => {
const picker = document.querySelector("#bounded-demo");
const today = new Date();
picker.minDate = today;
picker.maxDate = new Date(today.getFullYear(), today.getMonth() + 3, 0);
// Block weekends on top of the min/max bounds.
picker.isDateDisabled = (date) =>
date.getDay() === 0 || date.getDay() === 6;
});
</script>
Work week
work-week drops Saturdays/Sundays entirely from the day grid's HTML (a
5-column Mon–Fri grid instead of 7) rather than merely disabling them.
Keyboard navigation skips/nudges off weekends accordingly.
<eun-date-picker
work-week
label="Delivery date"
name="delivery-workweek"
></eun-date-picker>
Variants
variant matches every other text-like field (eun-input/eun-select/
eun-textarea): outline (default), fill, underline.
<eun-date-picker
variant="outline"
label="Outline"
name="outline-demo"
></eun-date-picker>
<eun-date-picker variant="fill" label="Fill" name="fill-demo"></eun-date-picker>
<eun-date-picker
variant="underline"
label="Underline"
name="underline-demo"
></eun-date-picker>
Part of the shared vocabulary covered in
Disabled and readonly
disabled blocks every interaction and greys the field out. readonly
keeps the value focusable/submittable but blocks editing and the calendar
trigger.
<eun-date-picker
disabled
label="Disabled"
name="disabled-demo"
default-value="2026-01-15"
></eun-date-picker>
<eun-date-picker
readonly
label="Readonly"
name="readonly-demo"
default-value="2026-01-15"
></eun-date-picker>
Hint, required, and error
hint shows helper text below the field, styled through the same
eun-label-adjacent description region every other field component uses.
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 date is invalid (e.g. a partially typed date, or a real but
non-existent one like February 31st), the border and outline switch to
--field-error-color at the same time. A hint set alongside required
still shows once the field is valid. Only invalid states replace it with
the error.
<eun-date-picker
label="Date of birth"
name="hint-demo"
hint="Format: day / month / year"
></eun-date-picker>
<eun-date-picker
label="Appointment date"
name="error-demo"
required
></eun-date-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/rangeValue
to default-value/default-range-value and
re-validates from there, exactly like every other Eunomia field.
Open/close animation
The calendar popover fades and scales open/closed by default, matching
eun-select's own dropdown transition.
--date-picker-panel-transition-duration (default 200ms) controls how
long that takes:
<eun-date-picker
label="Date of birth"
name="dob"
style="--date-picker-panel-transition-duration: 350ms"
></eun-date-picker>
Set no-animation to disable it: the panel then shows/hides instantly,
same as it already does automatically whenever the browser reports
prefers-reduced-motion: reduce:
<eun-date-picker
no-animation
label="Date of birth"
name="dob"
></eun-date-picker>
Custom
Override the --date-picker-*/--field-* CSS variables, listed in full
in the API tab.
<eun-date-picker
class="custom-date-picker"
label="Custom theme"
name="custom-demo"
></eun-date-picker>
.custom-date-picker {
--date-picker-selected-background: #be185d;
--date-picker-today-color: #be185d;
--field-border-color-focus: #be185d;
--field-focus-outline-color: #be185d;
}
Mobile layout
Below a 768px viewport, the calendar opens as a eun-modal instead of a
popover, with an explicit Apply/Cancel footer instead of an implicit
click-outside-to-close, easier to use with two taps on a touch screen. Swipe
left/right on the calendar to move to the previous/next month (or year, or
12-year page, depending on granularity). The prev/next header buttons
keep working exactly the same, the swipe is purely additive.
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 preview below is instead pinned to a real 375px-wide iframe, so it always renders the mobile layout live, right here: tap the calendar icon to open it.
Keyboard interactions: segmented field
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves focus between segments and the calendar trigger |
0-9 |
Types a digit, auto-advances to the next segment once full |
ArrowLeft/ArrowRight |
Moves to the previous/next segment |
ArrowUp/ArrowDown |
Increments/decrements the focused segment by 1 (day wraps 1–31, month wraps 1–12), seeds an empty segment from today's corresponding part first |
Backspace (on an empty segment) |
Moves to the previous segment |
Enter / Space (on the trigger) |
Opens the calendar |
Keyboard interactions: calendar grid
Follows the WAI-ARIA APG "Date Picker Dialog" pattern, adapted per
granularity: the day grid moves by day/week, the month grid by
month/quarter-row, the year grid by year/row within its 12-year page.
| Key | Day grid | Month grid | Year grid |
|---|---|---|---|
ArrowLeft/ArrowRight |
Previous/next day | Previous/next month | Previous/next year |
ArrowUp/ArrowDown |
Previous/next week | Previous/next row (±3 months) | Previous/next row (±3 years) |
Home / End |
Start/end of the focused week | January/December of the focused year | First/last year of the current page |
PageUp / PageDown |
Previous/next month | Previous/next year | Previous/next 12-year page |
Shift+PageUp/PageDown |
Previous/next year | Previous/next decade | Not applicable |
Enter / Space |
Selects the focused cell | Selects the focused cell | Selects the focused cell |
Escape |
Closes the popover | Closes the popover | Closes the popover |
Disabled cells (outside min-date/max-date, or rejected by
is-date-disabled) are skipped automatically while moving by arrow key,
and are real disabled native <button>s (with a redundant
aria-disabled alongside, belt-and-suspenders): excluded from the tab
order and announced as unavailable, not just visually dimmed. In
work-week mode the same skip logic also steps over the weekend cells
that the day grid omits from the DOM entirely, and Home/End/
PageUp/PageDown/the "Today" shortcut nudge onto the nearest weekday
whenever they'd otherwise land on a weekend that has no cell to receive
focus.
Aria attributes and rules
- Each segment is a native
<input inputmode="numeric">with its ownaria-label("Day"/"Month"/"Year"), grouped under arole="group" aria-label="…"per value (or per range endpoint, inmode="range"): the GOV.UK/USWDS accessible date-input pattern, chosen over a single free-text field specifically so a screen reader announces "Day, edit text" rather than having to parse a combined string. These segment labels are currently fixed English strings regardless oflocale. - The calendar trigger is
aria-haspopup="dialog"witharia-expandedtracking the popover/modal's open state, and returns focus to itself on close. Thatdialogrole describes the mobile modal accurately, while the desktop popover panel itself doesn't carry an explicitrole="dialog". - Every grid surface renders
role="grid"withrole="row"weeks/rows (the day grid's weekday header is its ownrole="row"ofrole="columnheader"cells),role="gridcell"cells witharia-selectedreflecting the current selection/range, and a single rovingtabindex="0"on the currently-focused cell (every other cell istabindex="-1"), standard roving-tabindex composite-widget navigation, matchingeun-scheduler's own month view for the same reason: one calendar-grid convention across the library, not a one-off for this component. The grid's prev/next/label header buttons (and the day grid's "Today" button) sit outside that roving-tabindex set, reachable byTablike any other button rather than by arrow-keying within the grid. - Each cell's
aria-labelis the fully-qualified value in the field's ownlocale(e.g. "Tuesday, 14 July 2026" for a day, "July 2026" for a month, "2026" for a year), never just the visible digit(s)/abbreviation, so the announced name doesn't depend on already knowing which month/year is displayed. Navigating between months/years/pages doesn't currently announce the newly-displayed period on its own (noaria-liveregion), so a screen-reader user only hears it once they arrow-key onto a cell. - Below the 768px breakpoint, the calendar renders inside
eun-modal(role="dialog", labelled by the field's ownlabelviaaria-label) instead of the popover, with an explicit Apply/Cancel footer: committing a selection with two taps is easier to reason about on a touch device than an implicit auto-close. Like everyeun-modal, focus isn't currently trapped inside it, andTab/Shift+Tabcan reach content outside the dialog, so this isn't yet a fully-conformant modal dialog. Treat it as a known gap rather than an intentional design choice. A finger swipe left/right on the grid steps to the previous/next period there too, purely additive to the prev/next header buttons, which keep working, on every pointer type, everywhere. - The header's month/year label (day grid) or year label (month grid) is
itself a button: clicking it drills up to a coarser grid (month, then
year) to jump the displayed period faster than paging one unit at a
time. Picking a cell there navigates and drills back down instead of
committing, only a pick at the field's own
granularitydoes that.
Reference links