Skip to content

Calendar

Form Inputs / Date and TimeSince 0.2.0
import { Calendar } from '@absoluteui/components/calendar';
import '@absoluteui/components/calendar/style.css';

Using Angular? See Calendar for Angular.

A month grid for browsing and choosing one date or, since 0.3.0, an inclusive date range. Calendar follows the WAI-ARIA APG date picker grid: a localized month-year heading, previous/next month buttons, and a table role="grid" with a roving tabindex. Since 0.3.0 it can show one to three consecutive months side by side (one grid per month, one roving tab stop across all of them) and select a range: the first pick sets an anchor, the days between the anchor and the hovered or focused day are previewed, and the second pick completes the range. Values are PlainDate and Range<PlainDate>. “Today” is the current date in the configured timezone, never the host machine’s zone. Date Picker and Date Range Picker compose it in a popup.

  • An always-visible calendar, for example on a booking or scheduling page.
  • Browsing dates where the day of week matters (appointments, deliveries).
  • An always-visible range calendar, for example two months on a booking page (selectionMode range, visibleMonths 2).
  • A form field for a known date such as a birth date: use Date Input.
  • A field with a calendar popup: use Date Picker, which composes Date Input and Calendar.
  • A date range field with typing and a popup: use Date Range Picker.
import { dayOfWeek, plainDate, plainDateToString } from '@absoluteui/core';
import { Calendar, type CalendarChangeDetail } from '@absoluteui/components/calendar';
const host = document.createElement('div');
document.body.append(host);
Calendar.create(host, {
label: 'Delivery date',
min: plainDate(2026, 10, 1),
max: plainDate(2026, 12, 31),
isDateDisabled: (date) => dayOfWeek(date) >= 6,
});
host.addEventListener('aui-change', (event) => {
const { value } = (event as CustomEvent<CalendarChangeDetail>).detail;
host.dataset.value = plainDateToString(value);
});
import { Calendar } from '@absoluteui/components/calendar';
const host = document.createElement('div');
document.body.append(host);
Calendar.create(host, { showWeekNumbers: true, firstDayOfWeek: 1 });
import { plainDate, plainDateToString } from '@absoluteui/core';
import { Calendar, type CalendarChangeDetail } from '@absoluteui/components/calendar';
const host = document.createElement('div');
document.body.append(host);
const calendar = Calendar.create(host, {
label: 'Stay',
selectionMode: 'range',
visibleMonths: 2,
min: plainDate(2026, 10, 1),
minDays: 2,
maxDays: 14,
});
calendar.setRange({ start: plainDate(2026, 10, 5), end: plainDate(2026, 10, 9) });
host.addEventListener('aui-change', (event) => {
const { range } = (event as CustomEvent<CalendarChangeDetail>).detail;
if (range) host.dataset.range = `${plainDateToString(range.start)}/${plainDateToString(range.end)}`;
});
import { configureAbsoluteUI } from '@absoluteui/core';
configureAbsoluteUI({ common: { timezone: 'Asia/Kolkata' }, components: { calendar: { showOutsideDays: false } } });
import { plainDateToString } from '@absoluteui/core';
import { Calendar, type CalendarChangeDetail } from '@absoluteui/components/calendar';
import '@absoluteui/components/calendar/style.css';
const host = document.querySelector<HTMLElement>('#calendar');
if (host) {
Calendar.create(host, { label: 'Appointment date', showWeekNumbers: true });
host.addEventListener('aui-change', (event) => {
const { value } = (event as CustomEvent<CalendarChangeDetail>).detail;
host.dataset.value = plainDateToString(value);
});
}

Range selection over two months (since 0.3.0):

import { plainDateToString } from '@absoluteui/core';
import { Calendar, type CalendarChangeDetail } from '@absoluteui/components/calendar';
const host = document.querySelector<HTMLElement>('#stay');
if (host) {
Calendar.create(host, { label: 'Stay', selectionMode: 'range', visibleMonths: 2, maxDays: 14 });
host.addEventListener('aui-change', (event) => {
const { range } = (event as CustomEvent<CalendarChangeDetail>).detail;
if (range) host.dataset.range = `${plainDateToString(range.start)}/${plainDateToString(range.end)}`;
});
}
import { plainDate } from '@absoluteui/core';
import { Calendar } from '@absoluteui/components/calendar';
const host = document.createElement('div');
document.body.append(host);
Calendar.create(host, { label: 'Delivery date', min: plainDate(2026, 10, 1) });
<div class="aui-calendar"> (consumer host)
<div class="aui-calendar__header" dir>
<button class="aui-calendar__nav aui-calendar__prev" aria-label="Previous month">
<div class="aui-calendar__heading" id aria-live="polite">September 2026</div>
<button class="aui-calendar__nav aui-calendar__next" aria-label="Next month">
</div>
<table class="aui-calendar__grid" role="grid" aria-labelledby=heading dir>
<thead><tr>
<th class="aui-calendar__week-header" scope="col" abbr="Week"> (showWeekNumbers)
<th class="aui-calendar__weekday" scope="col" abbr="Sunday">Sun</th> ...
</tr></thead>
<tbody><tr> (4-6 visible weeks)
<th class="aui-calendar__week-number" scope="row" aria-label="Week 36">
<td class="aui-calendar__day" tabindex="-1|0" aria-label="Thursday, September 10, 2026"
aria-selected aria-current="date" aria-disabled>10</td> ...
</tr></tbody>
</table>
</div>

With visibleMonths greater than 1 (since 0.3.0), the host gets aui-calendar--multi and holds one panel per month; the previous-month button sits in the first header, the next-month button in the last, and inner headers get a spacer:

<div class="aui-calendar aui-calendar--multi">
<div class="aui-calendar__months" dir>
<div class="aui-calendar__month">
<div class="aui-calendar__header"> prev | heading (aria-live) | <span class="aui-calendar__nav-spacer"> </div>
<table class="aui-calendar__grid" role="grid" aria-multiselectable="true"> ... </table> (range mode)
</div>
<div class="aui-calendar__month">
<div class="aui-calendar__header"> <span class="aui-calendar__nav-spacer"> | heading | next </div>
<table class="aui-calendar__grid" role="grid"> ... </table>
</div>
</div>
</div>

The focusable element is the td itself (the APG pattern), not a button inside it, because aria-selected is valid on gridcells but not on buttons.

Calendar.create(host: HTMLElement, options?: CalendarOptions): Calendar

Global configuration key: calendar (see Configuration).

Option Type Default Description
value PlainDate | null null Selected day (single mode; not used in range mode).
selectionMode 'single' | 'range' "single" Since 0.3.0. ‘range’: the first pick sets an anchor, the second completes the range (in either order); value is not used.
range Range<PlainDate> | null null Since 0.3.0. Selected inclusive range in range mode (start <= end).
minDays number Since 0.3.0. Range mode: minimum range length in days, counting the first and last day (integer 1-100000); shorter ends are disabled while the anchor is pending.
maxDays number Since 0.3.0. Range mode: maximum range length in days, counting the first and last day (integer 1-100000); longer ends are disabled while the anchor is pending.
visibleMonths number 1 Since 0.3.0. Consecutive months shown side by side (integer 1-3). Outside days are never shown with more than one month.
min PlainDate | null Inclusive lower bound; earlier days are disabled and focus never moves before it.
max PlainDate | null Inclusive upper bound.
isDateDisabled (date: PlainDate) => boolean Disable individual days (focusable, not selectable).
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).
focusedDate PlainDate Initially focused day and visible month. Default: value, else the range start, else today (clamped to min/max).
firstDayOfWeek 1 | 2 | 3 | 4 | 5 | 6 | 7 ISO weekday of the first column (1 = Monday, 7 = Sunday). Default: the locale’s first day of week.
label string Grid accessible name. Default: the visible month-year heading (aria-labelledby). With several months each grid is named ‘{label}, {month}’.
today PlainDate Advanced: fixed ‘today’ instead of the current date in timezone (tests, server rendering).
icons Partial<{ previous: IconRef; next: IconRef }> Icon slot overrides; mirrored in RTL.
showOutsideDays boolean true Show days of the adjacent months in the first and last week (one visible month only). Globally configurable.
showWeekNumbers boolean false Show ISO 8601 week numbers as row headers. Globally configurable.
locale string "en-US" Heading, weekday names, day numbers, cell names, and first day of week. Globally configurable. Inherited from common configuration.
timezone string (IANA) "UTC" Zone used only to determine today. Globally configurable. Inherited from common configuration.
direction 'ltr' | 'rtl' "ltr" Grid direction and ArrowLeft/ArrowRight mirroring. Globally configurable. Inherited from common configuration.
Property Type Description
host HTMLElement Consumer host; its children are owned by the component.
grid HTMLTableElement The table role=grid of the first visible month.
grids readonly HTMLTableElement[] Since 0.3.0. All month grids, first to last (one per visible month).
value PlainDate | null Selected day (single mode).
range Range<PlainDate> | null Since 0.3.0. Range mode: the selected range (not the pending anchor).
rangeAnchor PlainDate | null Since 0.3.0. Range mode: the first picked day while the range is incomplete, else null.
focusedDate PlainDate Day holding the roving tab stop.
visibleMonth { year: number; month: number } First visible month (1-12).
today PlainDate Current date in the configured timezone, or the today option.
options Readonly<CalendarOptions> Current instance options.
Method Description
setValue(value: PlainDate | null): void Programmatic selection (no events); a date also becomes the focused day.
setRange(range: Range<PlainDate> | null): void Since 0.3.0. Range mode: programmatic range (no events); a pending anchor is discarded and the first day becomes the focused day.
goTo(date: PlainDate): void Show the month of date (scrolling the visible months as needed) and make it the focused day (no events).
focus(): void Move DOM focus to the focused day.
update(options: Partial<CalendarOptions>): void Merge and apply options; changing range, selectionMode, minDays, or maxDays discards a pending anchor.
destroy(): void Remove rendering, listeners, and subscriptions.
Event Detail Bubbles Cancelable Description
aui-change { value: PlainDate; range?: Range<PlainDate> } Yes No Single mode: the user selected a day (click, Enter, or Space), including re-selecting the current day. Range mode: the user picked the last day; range is the completed range (the first pick emits nothing).
aui-navigate { month: { year: number; month: number } } Yes No The user changed the visible months (keyboard, month buttons, or an outside day); month is the first visible month.

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

Class Purpose
aui-calendar Host; –multi modifier with more than one visible month.
aui-calendar__months | __month Since 0.3.0. With several months: flex container (wraps when narrow) and one panel (header and grid) per month.
aui-calendar__header | __heading Header and month-year heading (the first heading is a polite live region).
aui-calendar__nav | __prev | __next Month navigation buttons (previous in the first header, next in the last).
aui-calendar__nav-spacer Since 0.3.0. Placeholder for the missing navigation button in inner month headers.
aui-calendar__grid table role=grid.
aui-calendar__weekday | __week-header | __week-number Column headers and week-number row headers.
aui-calendar__day Day gridcell; –today, –selected, –outside, –disabled, –empty modifiers; since 0.3.0 –in-range, –range-start, –range-end, and –preview (pending range) in range mode.
Slot Default icon
previous chevron-left
next chevron-right

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

--aui-border-width | --aui-color-border-strong | --aui-color-focus-ring | --aui-color-hover | --aui-color-on-primary | --aui-color-primary | --aui-color-selected | --aui-color-selected-text | --aui-color-text | --aui-color-text-disabled | --aui-color-text-muted | --aui-control-height-sm | --aui-duration-fast | --aui-easing-standard | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-md | --aui-font-size-sm | --aui-font-size-xs | --aui-font-weight-bold | --aui-font-weight-medium | --aui-font-weight-regular | --aui-font-weight-semibold | --aui-icon-size-sm | --aui-line-height-normal | --aui-radius-md | --aui-space-1 | --aui-space-2 | --aui-space-4

type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
interface CalendarMonth { readonly year: number; readonly month: number }
interface CalendarChangeDetail { value: PlainDate; range?: Range<PlainDate> } // range: range mode only
// CalendarOptions since 0.3.0:
// selectionMode?: 'single' | 'range'; range?: Range<PlainDate> | null;
// minDays?: number; maxDays?: number; visibleMonths?: number;
interface CalendarNavigateDetail { month: CalendarMonth }
interface CalendarIcons { previous: IconRef; next: IconRef }
function isoWeekNumber(date: PlainDate): number;

Pattern: WAI-ARIA APG Date Picker Dialog grid: table role=grid whose day cells (td) are the focusable gridcells with a roving tabindex. Since 0.3.0: one grid per visible month with a single roving tab stop across all grids, and range selection (aria-multiselectable grids).

Key Context Action
ArrowRight / ArrowLeft Day focused Next / previous day (mirrored in RTL); crosses months and moves between visible grids.
ArrowDown / ArrowUp Day focused Same weekday next / previous week.
Home / End Day focused First / last day of the week, respecting firstDayOfWeek.
PageDown / PageUp Day focused Same day next / previous month (clamped to the month length); all visible months scroll by one.
Shift+PageDown / Shift+PageUp Day focused Same day next / previous year (29 February clamps to 28).
Enter / Space Day focused, single mode Select the day (ignored for disabled days).
Enter / Space Day focused, range mode First pick: set the pending first day (announced politely). Second pick: complete the range in either order and emit aui-change.
Escape Day focused, first day pending Discard the pending first day (otherwise Escape is not handled by the Calendar).
Enter / Space Month button focused Show the previous / next month; focus stays on the button.
Tab / Shift+Tab Any Previous month, next month, then the one tabbable day (one tab stop across all visible months).
Element Role
table grid (one per visible month)
day td gridcell (implicit), focusable via tabindex
weekday th columnheader (scope=col)
week-number th rowheader (scope=row)
month buttons button (native)
heading none; the first month heading is a polite live region
icons none (aria-hidden=true)
Attribute Element Value
aria-labelledby | aria-label grid Month-year heading id, or the label option; with several months and a label, ‘{label}, {month}’ so each grid has a distinct name
aria-multiselectable grid “true” in range mode (since 0.3.0)
aria-live heading “polite” on the first visible month heading: month changes are announced once
aria-label day cell Localized full date (dateStyle full); in a committed range the first and last day add ‘, start of range’, ‘, end of range’, or ‘, start and end of range’
aria-selected day cell “true” on the selected day (single mode), on every day of a committed range, or on the pending first day (range mode)
aria-current day cell “date” on today
aria-disabled day cell “true” for out-of-range and consumer-disabled days, and, while a first day is pending, for days that would break minDays/maxDays (still focusable)
abbr weekday th Full weekday name; the visible text is abbreviated
aria-disabled month buttons “true” when the adjacent month is entirely out of range (stays focusable)

Exactly one day is tabbable across all visible months: the focused day (the selected day or range start, else today, clamped to min/max). Focus never moves outside min/max, and with several months the view stops at min and max so no month is entirely disabled. Days show a focus-visible outline in color-focus-ring (drawn inside the cell for days in a range).

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
2.1.1 Keyboard A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • Selected and today states are conveyed by aria-selected and aria-current, not by color alone (today also has a ring and bold text).
  • Disabled days remain focusable so keyboard users can move through them, as the APG recommends.
  • Outside days (one visible month only) are rendered with muted text and remain selectable; selecting one shows its month.
  • Range mode: the preview between the pending first day and the hovered or focused day is not a selection (no aria-selected) and is marked by an inner edge in addition to the fill, not by color alone.
  • Range mode: the pending first day is announced through the shared live region (“{date} selected as the first day. Choose the last day.”).
  • Screen-reader announcement of the full-date cell names, range day names, column headers, month changes, and the pending first day has not yet been manually verified with NVDA, JAWS, and VoiceOver.

default, focused day (focus-visible), selected, today, outside day, disabled day, empty cell (outside days hidden), first/last navigable month; range mode: range selected (start, in range, end), range pending (first day picked, preview).

From Trigger To
any arrow, Home/End, PageUp/PageDown key focused day moves (clamped to min/max); month re-rendered and aui-navigate when it changes
any month button adjacent month shown; focus stays on the button
enabled day click, Enter, Space (single mode) selected; aui-change
no pending day click, Enter, Space (range mode) first day pending (rangeAnchor), announced; days that would break minDays/maxDays disabled; no aui-change
first day pending pointer move or focus move preview between the first day and the hovered or focused day
first day pending click, Enter, Space on an enabled day range completed in either order; aui-change with range
first day pending Escape, setRange, or update of range/selectionMode/minDays/maxDays pending day discarded; the previous range is shown
disabled day click, Enter, Space no change
outside day click selected; its month is shown
  • Selected and today are conveyed by ARIA and by shape (fill, ring, bold), not by color alone.
  • Disabled days stay focusable so keyboard users can move through them (APG).
  • Focus never moves outside min/max; with several months the view also stops at min and max, so no visible month is entirely disabled.
  • The range preview is marked by an inner edge in addition to its fill, and range ends by the selected-day fill, not by color alone.
  • Manual screen-reader verification is pending (see Known limitations).

Intrinsic size: seven columns of control-height-sm cells (eight with week numbers). It does not stretch; give one month roughly 17rem, or let the container scroll horizontally on very narrow screens. Several months sit side by side (space-4 apart) and wrap one below the other when the container is narrow.

dir is set on the header, the grid, and (with several months) the months container from the configured direction, so columns and months run right to left. ArrowLeft/ArrowRight are mirrored and the chevrons are flipped to point in the reading direction.

Role tokens only; verified in the light, dark, and high-contrast themes. In forced-colors mode the selected day and range ends use Highlight/HighlightText, days in a range a dashed CanvasText outline, today a CanvasText outline, and disabled days GrayText.

configureAbsoluteUI({ common: { timezone: 'Asia/Kolkata' }, components: { calendar: { showWeekNumbers: true } } });

Changes apply to existing instances immediately.

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

Values are PlainDate and never shift. The configured timezone (default UTC, never the host zone) is used only to find “today”, which gets aria-current="date" and is the default focused day. Example with a fixed instant of 2026-03-15 10:30 UTC: today is 16 March in Pacific/Kiritimati (UTC+14) and 14 March in Pacific/Pago_Pago (UTC-11). The advanced today option pins the date for tests, server rendering, or demos.

icons.previous (default chevron-left) and icons.next (default chevron-right). Both are flipped in RTL.

All text comes from Intl and is set with textContent; no HTML parsing. CSP-safe.

No persistence, network access, or telemetry.

Six rows of seven cells are created once and reused for every month; navigation within a month only moves the tab stop. isDateDisabled runs once per visible day per render. Formatters are cached by core. The range preview updates only the range classes, without a full re-render. Budgets: under 5 ms average per Calendar.create across 100 instances; under 10 ms with visibleMonths: 2 and selectionMode: 'range'.

One to three months per instance (up to 42 day cells each).

  • PageDown from 31 January 2024 goes to 29 February 2024 (leap year); Shift+PageDown from there goes to 28 February 2025.
  • Week starts: Sunday for en-US, Monday for de-DE, Saturday for ar-EG (from getFirstDayOfWeek).
  • Week numbers are ISO 8601, taken from each row’s Monday: 1 January 2026 is in week 1; 31 December 2020 is in week 53.
  • min equal to max shows one selectable day, and both month buttons are aria-disabled.
  • With showOutsideDays: false, leading and trailing cells are empty and not focusable. With more than one visible month, outside days are never shown, so every date appears once.
  • Range mode: picking the last day before the first orders the range; picking the same day twice gives a one-day range.
  • Range mode: a range may include disabled days; only the first and last day are checked. minDays: 3 from 5 March allows 7 March as the last day (both ends count).
  • With several months and a max, the last visible month never goes past the month of max.
Code Category Cause
CALENDAR_OPTIONS configuration A date option is not a PlainDate, min > max, firstDayOfWeek outside 1-7, an empty label, or a non-function isDateDisabled; since 0.3.0 also an invalid selectionMode, a range that is not a range of PlainDate or has start > end (also from setRange), minDays/maxDays not an integer 1-100000, minDays > maxDays, or visibleMonths outside 1-3.
CFG_* configuration Invalid locale, timezone, direction, showOutsideDays, or showWeekNumbers.

A failed construction leaves no rendering, listeners, or subscriptions behind.

  • Gregorian calendar only; single-date or contiguous-range selection only.
  • A range may include disabled days: only the first and last day are checked; minDays and maxDays count both days.
  • At most three visible months; outside days are shown with one month only.
  • “Today” is computed on render; a calendar left open across midnight updates on the next navigation or configuration change.
  • Screen-reader behavior has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • Custom or HTML cell content.
  • More than three visible months.
  • Multiple non-contiguous dates.