Calendar
import { Calendar } from '@absoluteui/components/calendar';import '@absoluteui/components/calendar/style.css';Using Angular? See Calendar for Angular.
What it does
Section titled “What it does”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.
When to use
Section titled “When to use”- 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).
When not to use
Section titled “When not to use”- 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.
Examples
Section titled “Examples”Weekday-only calendar with bounds
Section titled “Weekday-only calendar with bounds”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);});ISO week numbers and a Monday week start
Section titled “ISO week numbers and a Monday week start”import { Calendar } from '@absoluteui/components/calendar';
const host = document.createElement('div');document.body.append(host);Calendar.create(host, { showWeekNumbers: true, firstDayOfWeek: 1 });Range selection over two months
Section titled “Range selection over two months”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)}`;});Today in the user’s timezone
Section titled “Today in the user’s timezone”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)}`; });}More usage
Section titled “More usage”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) });Anatomy
Section titled “Anatomy”<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.
API reference
Section titled “API reference”Factory
Section titled “Factory”Calendar.create(host: HTMLElement, options?: CalendarOptions): CalendarGlobal configuration key: calendar (see Configuration).
Options
Section titled “Options”| 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. |
Properties
Section titled “Properties”| 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. |
Methods
Section titled “Methods”| 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. |
Events
Section titled “Events”| 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. |
CSS classes
Section titled “CSS classes”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. |
Icon slots
Section titled “Icon slots”| Slot | Default icon |
|---|---|
previous |
chevron-left |
next |
chevron-right |
Design tokens
Section titled “Design tokens”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
TypeScript definitions
Section titled “TypeScript definitions”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;Accessibility
Section titled “Accessibility”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).
Keyboard
Section titled “Keyboard”| 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) |
ARIA attributes
Section titled “ARIA attributes”| 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).
WCAG 2.2 success criteria addressed
Section titled “WCAG 2.2 success criteria addressed”| 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.
Notes and known caveats
Section titled “Notes and known caveats”- 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.
Behavior
Section titled “Behavior”States
Section titled “States”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).
State transitions
Section titled “State transitions”| 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 |
Accessibility notes
Section titled “Accessibility notes”- 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 atminandmax, 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).
Responsive behavior
Section titled “Responsive behavior”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.
Right-to-left
Section titled “Right-to-left”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.
Theming
Section titled “Theming”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.
Global configuration
Section titled “Global configuration”configureAbsoluteUI({ common: { timezone: 'Asia/Kolkata' }, components: { calendar: { showWeekNumbers: true } } });Changes apply to existing instances immediately.
Component configuration
Section titled “Component configuration”Instance options override global and theme defaults. All options are dynamic.
Timezones
Section titled “Timezones”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.
Security
Section titled “Security”All text comes from Intl and is set with textContent; no HTML parsing. CSP-safe.
Data handling
Section titled “Data handling”No persistence, network access, or telemetry.
Performance
Section titled “Performance”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'.
Supported data scale
Section titled “Supported data scale”One to three months per instance (up to 42 day cells each).
Edge cases
Section titled “Edge cases”- 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 forde-DE, Saturday forar-EG(fromgetFirstDayOfWeek). - 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.
minequal tomaxshows one selectable day, and both month buttons arearia-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: 3from 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 ofmax.
Errors
Section titled “Errors”| 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.
Known limitations
Section titled “Known limitations”- Gregorian calendar only; single-date or contiguous-range selection only.
- A range may include disabled days: only the first and last day are checked;
minDaysandmaxDayscount 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.
Unsupported scenarios
Section titled “Unsupported scenarios”- Custom or HTML cell content.
- More than three visible months.
- Multiple non-contiguous dates.