Components / forms
Calendar
Week start comes from Intl.Locale.getWeekInfo where it exists. It is not in every engine yet, so the call is guarded and the fallback is Monday. This is the one place the component asks the platform something it may not know.
Summary
A month of days, navigable in two dimensions — one tab stop for the whole grid.
| APG pattern | Grid |
| Built on | native elements |
| Status | stable |
| Since | 0.2.0 |
Anatomy
Root: data-se="calendar". Explicit roles: grid, gridcell.
| Part | Element | Required |
|---|---|---|
day | <td> | yes |
grid | <table> | yes |
header | <div> | yes |
icon | <caret> | yes |
month | <div> | yes |
next | <button> | yes |
previous | <button> | yes |
sr | <span> | yes |
weekday | <th> | yes |
Properties
Open — see Calendar.d.ts.
States
| State | Expressed as | |
|---|---|---|
| Focused | :focus-visible | |
| Hover | :hover | |
| Disabled | aria-disabled |
Behavior
The view and the focus are two different things. Arrowing past the end of a month moves the view, because the focus has to land on a real day and that day is in the next month. month controls the view; the focused day is the grid's own business.
Focus is only restored after a key moved it. A calendar that grabs focus when it appears takes the reader somewhere they did not ask to go — so the effect that re-applies focus after a re-render checks that an arrow, not a render, caused the move.
Days outside the month are empty cells, not the neighbours' numbers. A 29 that belongs to the previous month invites a click that changes the selection and the view at once.
A blocked day stays in the grid. min, max and isDisabled mark a day aria-disabled rather than removing it: a day that vanishes changes the shape of the month under the reader, and the grid stops agreeing with the calendar it is drawing.
Accessibility
The month names the grid. aria-labelledby points at the month heading, so moving between months changes the grid's own accessible name and a screen reader reports it on entry — no live region needed for the common case. The heading is aria-live="polite" as well, for readers already inside the grid when the month moves.
Every day is named in full. The cell draws 7; its accessible name is the whole date in the reader's locale, because "7" tells someone arrowing through a month nothing about where they are.
The column headers are read long and drawn short. Mo is not a word. The short form is aria-hidden, the long one is visually hidden, and the abbr attribute carries it for anything reading the table natively.
Keyboard
| Key | What it does |
|---|---|
| Arrow left / right | The day before or after, crossing months |
| Arrow up / down | The same weekday, a week back or forward |
| Home / End | The first or last day of the focused week |
| Page Up / Page Down | The same day, a month back or forward |
| Shift + Page Up / Page Down | A year back or forward |
| Enter / Space | Select the focused day |
A month is one tab stop. Reaching the 28th by Tab would cost twenty-seven presses, which is the reason APG makes a calendar a grid rather than a list of buttons.
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-disabled | set at render |
aria-hidden | always true |
aria-label | set at render |
aria-labelledby | set at render |
aria-live | always polite |
aria-selected | set at render |
role | always grid, always gridcell |
Focus
One roving tab stop: the focused day carries tabindex="0" and every other day -1. Leaving and re-entering the grid returns to the day the reader left, not to the first of the month.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It sets no target size of its own; anything interactive inside it brings its own.
Tested
test/apg/calendar.test.jsx
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-accent | background |
--se-border | border |
--se-border-strong | box-shadow |
--se-border-width | border, box-shadow |
--se-control-h | block-size, inline-size |
--se-control-h-sm | block-size, inline-size |
--se-focus-color | outline |
--se-focus-offset | outline-offset |
--se-focus-width | outline, outline-offset |
--se-icon-sm | block-size, font-size, inline-size |
--se-radius-lg | border-radius |
--se-radius-md | border-radius |
--se-space-2 | gap, padding-block-end |
--se-space-3 | gap |
--se-space-4 | padding |
--se-surface | background |
--se-surface-sunk | background |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-on-intent | color |
Motion
None. A month that slides when it changes makes the reader wait to read it, and the heading has already said which month they are in.
Content
The month heading is the month and the year, written by Intl in the reader's locale. Never an abbreviation: March 2026, not Mar 26.
The weekday headers are the locale's short names, drawn, with the long name read.
Today is marked with a ring, not a fill. A filled today is indistinguishable from a selected day, and the two mean different things.
Usage
Pair it with a DateField when the date is known, and use it alone when it is being chosen. Typing a birthday beats twelve months of clicking; picking next Tuesday beats typing it.
weekStartsOn when you know. It is read from the locale where the browser can say and falls back to Monday, and guessing wrong shifts every date in the grid by a column.
When not to use it. For a month or a year on their own — a card expiry, a birth year. Those are two selects and no grid.
Notes
Every calculation is UTC, and that is not an implementation detail. A month built from local midnights straddles a daylight-saving boundary twice a year: one day is 23 hours long, and adding 24 hours to it lands on the same date twice. Nothing here reads a local component of a Date — only getUTC*, and only as arithmetic on a calendar date.
addMonths clamps the day. 31 January plus a month is 28 or 29 February, not 3 March. The naive version is what produces a Page Down that skips a month every spring.
Week start comes from Intl.Locale.getWeekInfo where it exists. It is not in every engine yet, so the call is guarded and the fallback is Monday. This is the one place the component asks the platform something it may not know.