Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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

eun-icon eun-modal eun-button eun-label · if it has a label
Overview API Examples Accessibility

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 (or is-date-disabled for irregular constraints) instead of validating the typed value yourself after the fact: disabled cells are skipped by keyboard navigation automatically
  • Let locale drive the segment order and month/weekday labels rather than hardcoding a "dd/mm/yyyy" assumption, since fr-FR and en-US genuinely 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 value as anything but a Date or null: 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's end is the 1st of the last included month, not its last day

Live testing

Properties

DatePicker <eun-date-picker>

Attributes

NameTypeDefaultDescription
with-timebooleanfalseAdds 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-format12 | 24Overrides the time picker panel's hour format. Auto-detected from locale when unset. Only relevant when withTime is set
step-minutenumber1The time picker panel's minute column increment step. Only relevant when withTime is set
min-timestringThe 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-timestringThe 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-valueDate | nullThe default value applied when the field connects, and restored on form reset, in single mode
week-starts-on0 | 11Which weekday a week starts on, Sunday or Monday. Only relevant with day granularity
work-weekbooleanfalseWhether to drop Saturdays and Sundays entirely from the day grid, as a five-column grid instead of seven. Only relevant with day granularity
no-animationbooleanfalseDisables 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
timezonestringThe 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-labelstring'Today'The label of the day grid's jump to today shortcut. Only shown with day granularity
apply-labelstring'Apply'The label of the mobile modal's confirm button
cancel-labelstring'Cancel'The label of the mobile modal's cancel button
aria-label-calendarstring'Open calendar'The accessible label of the trailing calendar button
day-placeholderstring'jj'The placeholder shown in the empty day segment
month-placeholderstring'mm'The placeholder shown in the empty month segment
year-placeholderstring'yyyy'The placeholder shown in the empty year segment
hour-placeholderstring'hh'The placeholder shown in the empty hour segment. Only relevant when withTime is set
minute-placeholderstring'mm'The placeholder shown in the empty minute segment. Only relevant when withTime is set
default-range-valueEunomiaDateRangeThe 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 Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
rangeValueEunomiaDateRange{start: null, end: null}The picked range, in range mode
minDateDateThe earliest selectable value
maxDateDateThe latest selectable value
isDateDisabled(date: Date) => booleanAn optional predicate disabling specific days, months, or years on top of minDate and maxDate
checkLocalValidatorsReads 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.
buildCommonValidatorsOverrides `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
valueDate | nullThe picked value, in single mode. Normalized to the first of the month or January first of the year outside day granularity
localestringThe locale used for month and weekday labels and the segments' field order. Defaults to the runtime's own
labelstringThe title label of the field, rendered through the label component
instructionsstringInstructions displayed below the label
namestringThe field name
disabledbooleanfalseWhether the field is disabled
readonlybooleanfalseWhether the field is read only
requiredbooleanfalseWhether the field is required
hideErrorbooleanfalseWhether the errors are hidden
validatorsArray<Validators<Date | null>>The list of custom validation rules applied to the value

Events

NameTypeDescription
eunchangeChangeEventFired 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 Events.

CSS custom properties

NameDescription
--field-border-colorSets 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-hoverSets the border color on hover
--field-border-color-focusSets the border color on focus
--field-backgroundSets the background color of the field
--field-text-colorSets the text color of the field
--field-placeholder-colorSets the color of the placeholder for empty segments
--field-icon-colorSets the color of the trailing calendar icon
--field-error-colorSets the color of the error message
--field-hint-colorSets the color of the hint message
--field-focus-outline-colorSets the focus outline color used throughout the field and calendar
--date-picker-hover-backgroundSets the background color of hovered calendar cells and navigation buttons
--date-picker-today-colorSets the accent color marking today, the current month, the current year, and the Today shortcut
--date-picker-outside-colorSets the text color of day grid cells outside the displayed month
--date-picker-weekday-colorSets the text color of the day grid's weekday labels
--date-picker-selected-backgroundSets the background color of the selected cell, in single mode
--date-picker-selected-text-colorSets the text color on top of date-picker-selected-background
--date-picker-range-backgroundSets 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-backgroundSets the background color of the popover panel
--date-picker-panel-transition-durationSets 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 Variants, alongside every other component that reuses it.

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 own aria-label ("Day"/"Month"/"Year"), grouped under a role="group" aria-label="…" per value (or per range endpoint, in mode="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 of locale.
  • The calendar trigger is aria-haspopup="dialog" with aria-expanded tracking the popover/modal's open state, and returns focus to itself on close. That dialog role describes the mobile modal accurately, while the desktop popover panel itself doesn't carry an explicit role="dialog".
  • Every grid surface renders role="grid" with role="row" weeks/rows (the day grid's weekday header is its own role="row" of role="columnheader" cells), role="gridcell" cells with aria-selected reflecting the current selection/range, and a single roving tabindex="0" on the currently-focused cell (every other cell is tabindex="-1"), standard roving-tabindex composite-widget navigation, matching eun-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 by Tab like any other button rather than by arrow-keying within the grid.
  • Each cell's aria-label is the fully-qualified value in the field's own locale (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 (no aria-live region), 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 own label via aria-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 every eun-modal, focus isn't currently trapped inside it, and Tab/Shift+Tab can 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 granularity does that.

Reference links

WAI-ARIA Authoring Practices: Date Picker Dialog
GOV.UK Design System: Date input
USWDS: Date picker