Calendar
A month grid the user picks one day or a range from. It owns selection, the range preview, the keyboard grid, and month paging. The app owns the value and the constraints; the trigger, the panel, and the field wiring belong to date-picker.
Dates cross the boundary as ISO YYYY-MM-DD strings. A trip date is a calendar day, not an instant, so no time zone drifts it and it posts straight into a hidden native input.
One day
The default mode. value is a string or null, and onChange fires with the clicked day. Today wears a dot under its numeral. min is set to today here, so every earlier day is struck through and the previous button disables at the edge of this month. Pressing the selected day again keeps it selected; clearing is value={null} from the app.
September 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
30 | 31 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
Leaving 2026-09-25.
September 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
30 | 31 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
Leaving 2026-09-25.
A range
mode={CalendarMode.Range} takes a { start, end } pair and reports one back once, on commit, with start never after end. Picking the end first normalizes. The endpoints fill --indicator and flatten the edge that faces the band; the days between them carry a band that starts and stops at the filled squares. Two taps is the range path: one on the first day, one on the last. Dragging across the grid works too, as a secondary path. While the first day is anchored, the pointer or the keyboard cursor previews the range in the same paint as a committed one; Escape, or focus leaving the grid, drops the anchor and restores the last committed value without firing onChange.
months={2} shows two grids side by side that page together. Tab into the grid and arrow across the range to see the focused day step one shade deeper on its own tint, with no ring.
October 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
1 | 2 | 3 | 4 | 5 | 6 | 7 |
November 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 |
2026-10-01 to 2026-10-08.
October 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
1 | 2 | 3 | 4 | 5 | 6 | 7 |
November 2026
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 |
2026-10-01 to 2026-10-08.
Locale and unavailable days
locale defaults to en-US and is applied through the library’s own provider, so the server and the client agree on weekday names, the week start, and the heading. Under de-DE the week opens on Monday. isDateDisabled receives each day as an ISO string and strikes the ones it refuses; they stay reachable by arrow key but cannot be selected. Weekends are struck below.
disabled on the whole calendar dims the root to half opacity, disables the previous and next buttons, and takes the grid out of the tab order. readOnly keeps the grid reachable and shows nothing, because the state of the value is the app’s to explain.
September 2026
| Mo | Di | Mi | Do | Fr | Sa | So |
|---|---|---|---|---|---|---|
31 | 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 |
September 2026
| Mo | Di | Mi | Do | Fr | Sa | So |
|---|---|---|---|---|---|---|
31 | 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 |
Props
One export, Calendar. mode is CalendarMode.Single by default or CalendarMode.Range, and the value and handler types follow it. aria-label is required. The rest: months (1 or 2), locale, min, max, isDateDisabled, disabled, readOnly, autoFocus, and className.
There is no visible-month control, no week-start prop, no range-length limit, and no error state. The visible month derives from the value, else today, else min. The week start follows the locale. Errors belong to date-picker.
Surface
Content only: the calendar draws no border and no background of its own. The host owns the surface, whether that is date-picker’s panel or a card on a page. Cells are 36px, six rows always so the height never changes between months, and days outside the month are empty, unfocusable cells. Every paint sits 2px inside the cell, so a filled day is a 32px rounded square and neighbours never touch; the band runs edge to edge between its endpoints with the same 2px inset above and below. Hover and keyboard focus paint one rounded background step and no ring, extending the borderless-item rule from ADR 0003 to a grid cell: an idle day steps to --accent, a day inside the band one shade deeper, and a filled endpoint to the step every indicator-filled surface uses. There is no press ring; the fill changing under the pointer is the feedback.
Motion
A month change slides the incoming grid in from 16px on the side it came from, with a fade, 150ms on the bounce curve as a CSS keyframe; the heading fades in at --motion-fast. The weekday header, the buttons, and the height stay still, and nothing exits: the outgoing cells read the visible month from the library’s context, so a copy held back for an exit would repaint onto the new month. Selection, the band, hover, and focus snap: the preview already moves cell by cell with the pointer, and a fade behind it would only lag. The one thing that moves inside a cell is an endpoint’s shape, its corners rounding or squaring and its flat edge sliding to the cell edge at --motion-fast as the range grows or shrinks. Calendar carries no motion dependency.
Accessibility
The keyboard map is React Aria’s, unmodified. Tab reaches the previous button, the next button, and then the grid as one stop, landing on the selected day or today. Arrows move across days and weeks and page the grid at a month edge; PageUp and PageDown move a month, with Shift a year; Home and End go to the first and last day of the month; Enter and Space select. Screen readers hear the grid’s name with the visible month, each day with its weekday and its today, selected, or unavailable status, and the range announcements the library provides.
Measured against this theme’s palette with an OKLCH-to-sRGB contrast check, on the two hosts the calendar sits in. Numerals sit at 17.5:1 and 15.5:1 on --popover and 18.3:1 and 17.5:1 on --background, light then dark. Muted weekday and unavailable text sits at 4.95:1 and 6.26:1 on --popover and 5.17:1 and 7.04:1 on --background. The numeral on a filled day sits at 5.75:1 in both modes. A keyboard-focused unavailable day takes the accent pair, --accent-foreground on --accent, at 14.5:1 and 9.6:1, because muted text on that step would fall to 3.9:1 in dark. The today dot sits at 3.04:1 and 5.11:1 on --popover and 3.17:1 and 5.75:1 on --background. Every text pair clears WCAG AA, so no fallback step is taken.