Skip to content

Date Range Picker

Form Inputs / Date and TimeSince 0.3.0
import { DateRangePicker } from '@absoluteui/components/date-range-picker';
import '@absoluteui/components/date-input/style.css';

Using Angular? See Date Range Picker for Angular.

An inclusive date range field with a calendar popup (WAI-ARIA APG date picker dialog, non-modal variant). DateRangePicker composes two Date Inputs (start and end, date granularity) inside one labelled role="group", plus a calendar button inside the end field; the button opens a range-selecting Calendar (one or two months) in a role="dialog" popup in the top layer. The value is a Range<PlainDate>, set only when both dates are valid and every range constraint passes. Typing and picking produce the same value. The picker, not its inner Date Inputs, is the form control that Form discovers.

  • Inclusive date ranges such as stays, trips, leave requests, and report periods.
  • Ranges with bounds, unavailable days, or a minimum or maximum length in days.
  • A single date: use Date Picker.
  • Two unrelated dates that do not form a range: use two Date Pickers.
  • Ranges of exact moments with a time of day: not supported (use two Date Inputs with minute granularity).
  • An always-visible range calendar: use Calendar with selectionMode ‘range’.
import { plainDate, plainDateToString } from '@absoluteui/core';
import { DateRangePicker, type DateRangePickerChangeDetail } from '@absoluteui/components/date-range-picker';
const host = document.createElement('div');
document.body.append(host);
DateRangePicker.create(host, {
label: 'Stay',
startLabel: 'Check-in',
endLabel: 'Check-out',
name: 'stay',
required: true,
min: plainDate(2026, 1, 1),
minDays: 2,
maxDays: 14,
});
host.addEventListener('aui-change', (event) => {
const { value } = (event as CustomEvent<DateRangePickerChangeDetail>).detail;
host.dataset.value = value ? `${plainDateToString(value.start)}/${plainDateToString(value.end)}` : '';
});

Initial range, weekdays only, and a consumer rule

Section titled “Initial range, weekdays only, and a consumer rule”
import { dayOfWeek, plainDate } from '@absoluteui/core';
import { DateRangePicker } from '@absoluteui/components/date-range-picker';
const host = document.createElement('div');
document.body.append(host);
DateRangePicker.create(host, {
label: 'Leave',
value: { start: plainDate(2026, 10, 5), end: plainDate(2026, 10, 9) },
startName: 'leaveFrom',
endName: 'leaveTo',
months: 1,
firstDayOfWeek: 1,
isDateDisabled: (date) => dayOfWeek(date) >= 6,
validate: (range) => (range && range.start.month !== range.end.month ? 'Request leave within one month.' : null),
});

Always two months, and keep the popup open after picking

Section titled “Always two months, and keep the popup open after picking”
import { configureAbsoluteUI } from '@absoluteui/core';
configureAbsoluteUI({ common: { timezone: 'Europe/Berlin' }, components: { dateRangePicker: { months: 2, closeOnSelect: false } } });
import { dayOfWeek } from '@absoluteui/core';
import { DateRangePicker } from '@absoluteui/components/date-range-picker';
import '@absoluteui/components/date-input/style.css';
import '@absoluteui/components/calendar/style.css';
import '@absoluteui/components/date-range-picker/style.css';
const host = document.querySelector<HTMLElement>('#leave');
if (host) {
const picker = DateRangePicker.create(host, { label: 'Leave', name: 'leave', maxDays: 10, isDateDisabled: (d) => dayOfWeek(d) >= 6 });
host.addEventListener('aui-change', () => {
if (!picker.validity.valid) picker.reportValidity();
});
}
import { plainDate } from '@absoluteui/core';
import { DateRangePicker } from '@absoluteui/components/date-range-picker';
const host = document.createElement('div');
document.body.append(host);
DateRangePicker.create(host, { label: 'Stay', name: 'stay', required: true, min: plainDate(2026, 1, 1), minDays: 2 });
<div class="aui-date-range-picker" data-aui-control> (consumer host; registered with Form)
<div class="aui-date-range-picker__group" role="group" aria-labelledby=label aria-describedby=description>
<div class="aui-date-range-picker__label" id>Stay</div>
<div class="aui-date-range-picker__fields">
<div class="aui-date-range-picker__start aui-date-input"> (start Date Input; not registered)
<div class="aui-date-input__label">Start date</div> (visually hidden)
<div class="aui-date-input__group" role="group"> segments ... </div>
<div class="aui-date-input__description"> <div class="aui-date-input__hint"> <div class="aui-date-input__error">
<input type="hidden" name=startName>
</div>
<span class="aui-date-range-picker__separator" aria-hidden="true">-</span>
<div class="aui-date-range-picker__end aui-date-input"> (end Date Input; not registered)
<div class="aui-date-input__label">End date</div> (visually hidden)
<div class="aui-date-input__group" role="group">
<div class="aui-date-input__fields"> segments ... </div>
<div class="aui-date-range-picker__trailing"> (Date Input `trailing` option)
<button class="aui-date-range-picker__button" aria-haspopup="dialog" aria-expanded aria-controls aria-label="Choose dates">
<div class="aui-date-range-picker__popup" role="dialog" aria-modal="false" aria-label="Choose dates" popover="manual">
<div class="aui-calendar"> ... </div> (selectionMode 'range')
</div>
</div>
</div>
<div class="aui-date-input__description"> <div class="aui-date-input__hint"> <div class="aui-date-input__error">
<input type="hidden" name=endName>
</div>
</div>
<div class="aui-date-range-picker__description" id hidden>
<input type="hidden" name> (ISO 8601 interval)
</div>
</div>

The group label names both fields; each field keeps its own accessible name (“Start date”, “End date”, or startLabel/endLabel), visually hidden. The button and popup sit inside the end field’s group in the DOM (the popup renders in the top layer), so moving focus between the end segments, the button, and the calendar does not mark the end field touched, and a picker inside a modal Dialog stays inside the dialog’s subtree.

DateRangePicker.create(host: HTMLElement, options: DateRangePickerOptions): DateRangePicker

Global configuration key: dateRangePicker (see Configuration).

Option Type Default Description
label string Visible group label and accessible name of the role=group around both fields. Required.
startLabel string Accessible name of the start field (visually hidden). Default: localized ‘Start date’.
endLabel string Accessible name of the end field (visually hidden). Default: localized ‘End date’.
value Range<PlainDate> | null null Initial inclusive range (start <= end); also the value restored by reset().
min PlainDate | null Inclusive lower bound for both dates (validation and calendar).
max PlainDate | null Inclusive upper bound for both dates (validation and calendar).
minDays number Minimum range length in days, counting the start and end day (integer 1-100000).
maxDays number Maximum range length in days, counting the start and end day (integer 1-100000).
name string Form field name; Form and the hidden input carry the ISO 8601 interval YYYY-MM-DD/YYYY-MM-DD.
startName string Field name of the start date’s own hidden input (YYYY-MM-DD) for native form posts.
endName string Field name of the end date’s own hidden input (YYYY-MM-DD) for native form posts.
description string Help text referenced by the group’s aria-describedby.
required boolean false Both dates are required (value-missing).
disabled boolean false Disables both fields and the calendar button; a disabled picker is not submitted.
readOnly boolean false Prevents editing; the calendar button is disabled.
isDateDisabled (date: PlainDate) => boolean Disable days in the calendar; a typed disabled start or end day is invalid (‘date-unavailable’). Days between the ends are not checked.
businessCalendar BusinessCalendarEngine | null Business calendar (since 0.9.0): non-working days and holidays are marked and named in the calendar.
disableNonWorking boolean "false" With businessCalendar: non-working days cannot be picked or typed (since 0.9.0).
validate (value: Range<PlainDate> | null) => string | null Consumer validation, run when the field and range constraints pass; return a message to mark the value invalid (error ‘custom’).
firstDayOfWeek 1 | 2 | 3 | 4 | 5 | 6 | 7 First calendar column (ISO weekday). Default: the locale’s first day of week.
icons Partial<{ calendar: IconRef }> Icon slot overrides.
closeOnSelect boolean true Close the popup after the last day of the range is picked. Globally configurable.
months 'auto' | 1 | 2 "auto" Months in the popup. ‘auto’: two when the viewport is at least 40rem wide when the popup opens, else one. Applied on the next open. Globally configurable.
locale string "en-US" Segment order, calendar, messages, formatting; forwarded to all children. Globally configurable. Inherited from common configuration.
timezone string (IANA) "UTC" Zone used only to determine today in the calendar. Globally configurable. Inherited from common configuration.
direction 'ltr' | 'rtl' "ltr" Arrow-key mirroring, calendar direction, and popup alignment. Globally configurable. Inherited from common configuration.
Property Type Description
host HTMLElement Consumer host; its children are owned by the component. Registered with Form.
group HTMLDivElement The labelled role=group around both fields.
startInput DateInput Start date field (date granularity); not registered with Form.
endInput DateInput End date field; the calendar button and popup sit inside its boundary. Not registered with Form.
calendar Calendar The range Calendar (selectionMode ‘range’) inside the popup.
button HTMLButtonElement Calendar button inside the end field boundary.
popup HTMLDivElement role=dialog aria-modal=false popup (popover=manual).
value Range<PlainDate> | null The inclusive range, or null when empty, incomplete, or failing a field or range check (a custom error from validate or setCustomError keeps the range).
validity DateRangePickerValidity { valid, error, message, field }; field is the field that shows the message (‘start’ | ‘end’ | null).
isOpen boolean Whether the popup is open.
name string | null Form field name.
label string Group label text.
disabled boolean Disabled state.
dirty boolean Either date differs from its initial value.
touched boolean Focus has left either date field at least once.
options Readonly<DateRangePickerOptions> Current instance options.
Method Description
open(): void Open the calendar on the range start (else the typed start date, else today) and focus it. No-op when disabled or read-only.
close(): void Close the calendar; an unfinished pick is discarded. Focus inside the popup returns to the calendar button.
setValue(value: Range<PlainDate> | null): void Programmatic value (no aui-change); writes both fields and clears an external custom error. Throws when start > end.
update(options: Partial<DateRangePickerOptions>): void Merge and apply options to both fields and the calendar; closes the popup when disabled or read-only.
reportValidity(): boolean Show both fields’ messages now and return validity.
setCustomError(message: string | null): void External error (server, cross-field rule), shown on the end field; cleared when the user changes either date. null or ‘’ clears it.
getFormValue(): string | null ISO 8601 interval YYYY-MM-DD/YYYY-MM-DD, or null when there is no valid range.
focus(): void Focus the first segment of the start date.
reset(): void Close, restore the initial range, and clear the custom error and touched/submitted state (no aui-change).
destroy(): void Close and remove rendering, listeners, registration, and subscriptions of the picker and its children.
Event Detail Bubbles Cancelable Description
aui-change { value: Range<PlainDate> | null; validity: DateRangePickerValidity } Yes No User typing or a completed calendar pick changed the value or validity. Dispatched from the picker host; the inner Date Input and Calendar change events are not exposed.
aui-navigate { month: { year: number; month: number } } Yes No Bubbles from the inner Calendar when the user changes the visible months; month is the first visible month.
aui-open null No No The popup opened.
aui-close null No No The popup closed.

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-date-range-picker Host; –disabled modifier.
aui-date-range-picker__group The labelled role=group.
aui-date-range-picker__label Visible group label (with the required indicator).
aui-date-range-picker__fields Flex row of start field, separator, and end field; wraps when narrow.
aui-date-range-picker__start | __end Hosts of the inner Date Inputs (aui-date-input classes apply inside; their labels are visually hidden).
aui-date-range-picker__separator Decorative en dash between the fields (aria-hidden).
aui-date-range-picker__description Help text.
aui-date-range-picker__trailing Button and popup container inside the end Date Input group.
aui-date-range-picker__button Calendar button.
aui-date-range-picker__popup Top-layer dialog popup containing the Calendar (aui-calendar classes apply inside).
Slot Default icon
calendar calendar

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-border-width | --aui-color-border | --aui-color-focus-ring | --aui-color-hover | --aui-color-surface | --aui-color-text | --aui-color-text-disabled | --aui-color-text-muted | --aui-control-height-md | --aui-duration-fast | --aui-easing-standard | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-sm | --aui-font-weight-medium | --aui-icon-size-sm | --aui-line-height-tight | --aui-radius-md | --aui-shadow-md | --aui-space-1 | --aui-space-2 | --aui-space-3

type DateRangePickerMonths = 'auto' | 1 | 2;
interface DateRangePickerConfig { closeOnSelect: boolean; months: DateRangePickerMonths }
interface DateRangePickerIcons { calendar: IconRef }
type DateRangePickerError = DateInputError | 'incomplete-range' | 'range-reversed' | 'range-too-short' | 'range-too-long' | 'date-unavailable';
interface DateRangePickerValidity { readonly valid: boolean; readonly error: DateRangePickerError | null; readonly message: string; readonly field: 'start' | 'end' | null }
interface DateRangePickerChangeDetail { value: Range<PlainDate> | null; validity: DateRangePickerValidity }
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;

Pattern: WAI-ARIA APG Date Picker Dialog, non-modal variant, for a range: a labelled role=group containing two labelled segmented date fields (Date Input), a button with aria-haspopup=dialog inside the end field, and a role=dialog popup containing a range-selecting Calendar grid (one or two months).

Key Context Action
Enter / Space Calendar button Open (or close) the calendar; focus moves to the range start, else the typed start date, else today.
Alt+ArrowDown Date segment of either field focused Open the calendar (the segment value does not change).
Calendar keys Day focused Arrows, Home/End, PageUp/PageDown, Shift+PageUp/PageDown as in Calendar, across all visible months.
Enter / Space Day focused, no pending first day Pick the first day of the range; it is announced and the popup stays open.
Enter / Space Day focused, first day pending Pick the last day (either order), write both fields, close the popup (closeOnSelect), and focus the calendar button.
Escape Popup open Close the popup, discarding a pending first day, and return focus to the calendar button. Inside a Dialog, only the popup closes.
Tab / Shift+Tab Popup open Moves through month buttons and the day; leaving the popup (other than to the calendar button) closes it.
Date Input keys Segment focused As in Date Input; Tab moves from the start field to the end field.
Element Role
range group group, aria-labelledby the visible label
start and end fields group (Date Input), each with its own visually hidden label
separator none (aria-hidden=true)
calendar button button (native), aria-haspopup=dialog
popup dialog (aria-modal=false)
calendar grid per month (Calendar, aria-multiselectable=true)
icons none (aria-hidden=true)
Attribute Element Value
aria-labelledby range group Id of the visible label
aria-describedby range group Help text id when description is set
aria-labelledby start / end field group Id of the field’s visually hidden label (startLabel / endLabel, default “Start date” / “End date”)
aria-label calendar button “Choose dates”, or “Change dates, <long start date> to <long end date>” when a range is set
aria-expanded calendar button “true” while the popup is open
aria-controls calendar button Popup id
aria-label popup Localized “Choose dates”
aria-modal popup “false” (non-modal; the page stays reachable)
aria-invalid / aria-describedby segments of the field that shows the error Set by that Date Input when its message is shown

Opening moves focus into the grid; closing with focus inside the popup returns it to the calendar button. Outside pointer presses close the popup. The button and popup sit inside the end field, so moving focus between the end segments, the button, and the popup does not mark the end field touched; leaving the start field marks the start field touched.

Criterion Level Note
1.3.1 Info and Relationships A
1.4.1 Use of Color A
1.4.3 Contrast (Minimum) AA
1.4.11 Non-text Contrast AA
1.4.13 Content on Hover or Focus AA
2.1.1 Keyboard A
2.1.2 No Keyboard Trap A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
3.3.1 Error Identification A
3.3.2 Labels or Instructions A
3.3.3 Error Suggestion AA
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • The segments remain the primary input; the calendar is an alternative (APG).
  • The popup is non-modal: focus is not trapped, and the rest of the page stays reachable.
  • Each field has its own accessible name (visually hidden) inside the labelled group, so screen readers announce the group label and ‘Start date’ or ‘End date’.
  • Range errors are shown on the field they concern (for example a reversed range on the end field) through that Date Input’s message and aria-describedby.
  • The calendar names the first and last day (‘start of range’, ‘end of range’) and announces the pending first day through a polite live region.
  • A typed start or end day rejected by isDateDisabled produces a localized message, so the typed and picked paths agree.
  • Screen-reader behavior (group and field names, dialog announcement, range day names, the pending-day announcement) has not yet been manually verified with NVDA, JAWS, and VoiceOver.

empty, partial (one date, or an incomplete date), valid, invalid (Date Input errors, incomplete range, reversed, too short, too long, unavailable day, custom), open, open with a pending first day, disabled, read-only.

From Trigger To
closed button, or Alt+ArrowDown on a segment of either field open; focus on the range start, the typed start date, or today; aui-open
open pick a day (no pending day) first day pending and announced; no aui-change
open, first day pending pick a day range completed (either order); both fields written; aui-change if changed; closed and focus on the button (closeOnSelect)
open, first day pending Escape, pointer outside, or Tab out closed; the pending day is discarded and the previous range stays
open Escape closed; focus on the button; aui-close
any user types in either field aui-change when the value or validity changes
any reset() closed, initial range, pristine, untouched
  • The segments stay the primary input; the calendar is an alternative.
  • The popup is non-modal: focus is not trapped and the page stays reachable. Escape and outside presses close it through the shared overlay stack, so only the topmost overlay responds.
  • Range errors are shown on the field they concern, with that Date Input’s message timing.
  • A typed start or end day rejected by isDateDisabled gets a localized message, so the typed and picked paths agree.
  • Manual screen-reader verification is pending.

The start and end fields sit on one line and wrap below each other when space runs out. The popup is positioned under the fields (bottom-start), flips above when there is no room, and scrolls when taller than the available height (--aui-available-height). With months: 'auto', the popup shows two months when the viewport is at least 40rem wide when it opens, else one; the Calendar’s months wrap when the popup is narrower than two months.

Segment order follows the locale; the button sits at the inline end of the end field; the popup aligns to the fields’ inline start; the calendar mirrors arrows and chevrons.

Role tokens only; verified in the light, dark, and high-contrast themes. In forced-colors mode the popup has a CanvasText border and a disabled button uses GrayText.

configureAbsoluteUI({ common: { timezone: 'Europe/Berlin' }, components: { dateRangePicker: { months: 2, closeOnSelect: false } } });

Changes apply to existing instances immediately (months on the next open). Configuration changes that do not change locale, timezone, or direction do not rebuild the fields, so partially typed input is kept.

Instance options override global and theme defaults. All options are dynamic.

Values are PlainDate ranges and never shift. The configured timezone (default UTC, never the host zone) only determines “today” in the calendar, which is focused when the fields are empty.

icons.calendar (default calendar).

All text uses textContent and Intl formatting; no HTML parsing. The popup uses the Popover API; no inline styles or handlers. CSP-safe.

No persistence, network access, or telemetry. When name is set, a hidden input carries the ISO 8601 interval YYYY-MM-DD/YYYY-MM-DD (empty when there is no valid range); Form gets the same string. startName and endName give each inner Date Input’s hidden input a name (YYYY-MM-DD) for native form posts. A disabled picker is not submitted.

Two Date Inputs and one Calendar per picker, created once; opening sets the month count and range and re-renders only the calendar. Budget: under 15 ms average per DateRangePicker.create across 100 instances.

100 pickers per page.

  • Picking the last day before the first one orders the range; picking the same day twice gives a one-day range.
  • With a first day pending, days that would make the range shorter than minDays or longer than maxDays are disabled.
  • minDays and maxDays count both ends: 5 to 7 March is 3 days. maxDays: 1 gives the message “Choose a single day.”
  • A range may include disabled days: isDateDisabled is checked on the start and end only.
  • Closing with a first day pending keeps the previous range; the next open starts fresh.
  • Typing a start date only gives incomplete-range (“Enter an end date.”) on the end field, shown after the end field is touched or on reportValidity().
  • validate also runs when both fields are empty (value null).
  • Opening from an empty required field does not show “This field is required.” until focus leaves the field.
  • Inside a modal Dialog, the first Escape closes the popup and the second closes the dialog.
Code Category Cause
DATE_RANGE_PICKER_OPTIONS configuration Missing or empty label, empty startLabel/endLabel, value not a range of PlainDate or start > end (also from setValue), min/max not a PlainDate, min > max, minDays/maxDays not an integer 1-100000, minDays > maxDays, or a non-function isDateDisabled/validate.
CALENDAR_OPTIONS configuration firstDayOfWeek outside 1-7.
CFG_* configuration Invalid locale, timezone, direction, closeOnSelect, or months.
Validation (validity.error) validation See below.
validity.error Field Message
Date Input errors (incomplete, invalid-date, value-missing, range-underflow, range-overflow) the field concerned (start first) Date Input messages
incomplete-range the empty field “Enter a start date.” / “Enter an end date.”
range-reversed end “Enter an end date on or after the start date.”
date-unavailable start or end “This date is not available.”
range-too-short end “Choose a range of at least {count} days.”
range-too-long end “Choose a range of {count} days or fewer.” (maxDays: 1: “Choose a single day.”)
custom end validate result, then the external setCustomError message

Precedence follows the table order. Date Input errors other than incomplete and value-missing show immediately; incomplete, required, range, and custom messages show after the field is touched or on reportValidity(). A failed construction leaves no rendering, listeners, registrations, or subscriptions behind.

  • Gregorian calendar only; years 1 to 9999.
  • A range may include disabled days: only the start and end days are checked against isDateDisabled.
  • minDays and maxDays count both the start and the end day.
  • Validation messages follow Date Input timing; validationTiming is not supported.
  • validity reports one error at a time.
  • Closing the popup with only the first day picked discards that pick.
  • No Cancel/OK buttons: picking the last day commits.
  • Screen-reader behavior has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • Time of day in either field.
  • Open-ended ranges (one date only) as a valid value.
  • More than two months in the popup.
  • HTML in the label, help text, or messages.