Calendar grid
StableOne month as an ARIA grid with the APG date-picker keys. Every picker in the kit is built on it.
It keeps no state: the parent owns the focused date (which also decides the month) and the value or range, so two grids side by side can share one focus. It handles single dates and ranges with a preview band, min and max, unavailable days, ISO week numbers and up to three event squares per day. The week starts on the dates locale’s first day unless you set firstDay.
Bring your own header
import { useId, useState } from 'react'import { CalendarGrid } from '@rchursin/product/calendar'import { parseISODate, shiftMonths } from '@rchursin/product/calendar/dates'import { formatMonth } from '@rchursin/product/lib/dates'import { IconButton } from '@rchursin/product/ui'
/** * The grid keeps no state. You own the focused date (it also decides the month on show) and the * value, and bring your own header. Click a date, or Tab in and use the arrow keys. */export default function CalendarGridBasic() { const titleId = useId() const [focused, setFocused] = useState('2026-10-21') const [value, setValue] = useState<string | null>('2026-10-21')
return ( <div className="flex flex-col gap-2"> <div className="flex items-center gap-0.5"> <p id={titleId} aria-live="polite" className="flex-1 pl-1 text-[13px] font-semibold tracking-[-0.01em] tabular-nums"> {formatMonth(parseISODate(focused) ?? new Date(2026, 9, 1))} </p> <IconButton icon="chevron-left" label="Previous month" size="sm" onClick={() => setFocused(shiftMonths(focused, -1))} /> <IconButton icon="chevron-right" label="Next month" size="sm" onClick={() => setFocused(shiftMonths(focused, 1))} /> </div> <CalendarGrid month={focused} focusedDate={focused} onFocusChange={setFocused} value={value} onSelect={setValue} labelledBy={titleId} /> <p className="pl-1 font-mono text-[11px] text-fg-3">value: {value ? `"${value}"` : 'null'}</p> </div> )}Usage
import { CalendarGrid } from '@rchursin/product/calendar'const [focused, setFocused] = useState('2026-10-21')
<CalendarGrid month={focused} focusedDate={focused} onFocusChange={setFocused} value={date} onSelect={setDate} label="October 2026" />Examples
States
Small size, ISO week numbers, weeks from Monday, October only, three days away (struck through) and squares for each day’s sessions.
import { useId, useState } from 'react'import { CalendarGrid } from '@rchursin/product/calendar'
/** Sample bookings for a shared test device: how many sessions each day has, and the days it is away */const sessions: Record<string, number> = { '2026-10-05': 1, '2026-10-07': 2, '2026-10-14': 3, '2026-10-15': 1, '2026-10-21': 2, '2026-10-28': 4 }const away = new Set(['2026-10-12', '2026-10-13', '2026-10-26'])
/** * Week numbers, a min and max, unavailable days (struck through, still reachable, announced as * unavailable) and up to three squares for a day's sessions. Weeks start on Monday here. */export default function CalendarGridStates() { const titleId = useId() const [focused, setFocused] = useState('2026-10-21') const [value, setValue] = useState<string | null>(null)
return ( <div className="flex max-w-60 flex-col gap-2"> <p id={titleId} className="pl-1 text-[13px] font-semibold tracking-[-0.01em]"> Test device · October 2026 </p> <CalendarGrid labelledBy={titleId} month="2026-10-01" focusedDate={focused} onFocusChange={setFocused} value={value} onSelect={setValue} min="2026-10-01" max="2026-10-31" isDateUnavailable={(iso) => away.has(iso)} markers={(iso) => sessions[iso] ?? 0} weekNumbers firstDay={1} size="sm" /> <p className="pl-1 text-xs/5 text-fg-3">Squares: sessions booked (sample). Struck through: the device is away.</p> </div> )}Two months, one focus
Wrapped in data-calendar-root, the arrow keys walk from October into November. Range mode draws the band; preview follows the focus until the end is chosen.
import { useId, useState } from 'react'import { CalendarGrid, type DateRange } from '@rchursin/product/calendar'
const months = [ { first: '2026-10-01', name: 'October 2026' }, { first: '2026-11-01', name: 'November 2026' },]const order = (a: string, b: string): DateRange => (a <= b ? { start: a, end: b } : { start: b, end: a })
/** * Two grids, one focus: wrap them in an element with data-calendar-root and the arrow keys walk * from October into November. Range mode draws a band; `preview` follows the focus until the end is chosen. */export default function CalendarGridTwoMonths() { const id = useId() const [focused, setFocused] = useState('2026-10-26') const [range, setRange] = useState<DateRange | null>({ start: '2026-10-26', end: '2026-11-06' }) const [anchor, setAnchor] = useState<string | null>(null)
function pick(iso: string) {API
Calendar grid props
| Prop | Type | Default | Description |
|---|---|---|---|
monthrequired | ISODate | – | The month to show: any ISO date inside it. |
focusedDaterequired | ISODate | – | The date in the Tab order (roving tabindex). |
onFocusChangerequired | (date: ISODate) => void | – | The focus moved (keys, click, or focus arriving another way). Keep the month in step if you page with it. |
onSelect | (date: ISODate) => void | – | Click, Enter or Space on an available date. |
value | ISODate | null | – | The selected date (single mode). |
range | DateRange | null | – | The selected range (range mode): solid ends and a band between, both ends inclusive. |
preview | DateRange | null | – | A tentative range drawn lighter, with dashed ends, while the end is being picked. |
min | ISODate | – | Earliest date that can be chosen. The keys clamp to it. |
max | ISODate | – | Latest date that can be chosen. The keys clamp to it. |
isDateUnavailable | (date: ISODate) => boolean | – | Extra dates that can’t be chosen (booked, weekends). Struck through, still focusable. |
weekNumbers | boolean | – | ISO week numbers down the side, as row headers. |
markers | (date: ISODate) => number | – | How many events fall on a date: up to three squares under the number; the count is in the date’s name. |
firstDay | number | firstDayOfWeek() | 0 = Sunday. Defaults to the dates locale (getDateLocale). |
outsideDays | 'show' | 'hide' | 'show' | Days from neighbouring months: muted, or left blank (for two-month views). |
size | 'md' | 'sm' | 'md' | Cells of 36 or 28 pixels. |
labelledBy | string | – | id of the element that names the grid (usually the month title). |
label | string | – | Accessible name when there is no title to point at. |
onHoverDate | (date: ISODate | null) => void | – | Pointer hover over a date, null when it leaves (for a range preview). |
focusedRef | RefObject<HTMLButtonElement | null> | – | Filled with the cell in the Tab order, for a popover’s first focus. |
className | string | – | Classes on the table. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Left arrowRight arrow | Previous or next day (swapped in right-to-left layouts). |
| Up arrowDown arrow | The same weekday a week earlier or later. |
| HomeEnd | First or last day of the week, in the dates locale (Sunday or Monday first; see setDateLocale). |
| Page UpPage Down | The same day in the previous or next month, clamped (Mar 31 goes to Feb 28). |
| ShiftPage UpShiftPage Down | The same day a year earlier or later. |
| EnterSpace | Calls onSelect with the focused date (unless it is unavailable). |
| Tab | One stop for the whole grid: the focused date. |
- A real <table role="grid">: weekday headers carry the full name (“Wednesday”) and week numbers are row headers.
- Each date’s name carries its state: “Today, Tuesday, October 13, 2026, selected, 2 events”, because VoiceOver ignores aria-selected on grid cells. Today also gets aria-current="date".
- Range mode sets aria-multiselectable and names the ends (“start of selected range”, “end of selected range”).
- Unavailable dates use aria-disabled, so they stay reachable and say “unavailable” instead of vanishing from the keyboard path.
- Focus is a 2px outline in the text colour; the selected date is solid metal and today has a small square, so no state is colour alone.
- Today is marked once the page runs in the browser: a prerendered page can’t know which day it will be read on, so its HTML marks no day and hydration never disagrees.
Guidelines
Do
- Give every grid a name: labelledBy the month title, or label.
- Clamp keyboard travel with min and max when only some months are on show.
Don’t
- Don’t hide unavailable dates; strike them through so people know why.
- Don’t keep a second “month” state that can drift from focusedDate.
In use
Related
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
- Mini calendarA small month for sidebars and widgets, with up to three squares under days that have events.
- Range pickerA start and end date for report filters, with analytics presets, typed ends and two linked months.
- Date helpersSmall date maths and Intl formatting on plain Dates: month grids, week starts from the locale, ISO weeks, relative days, durations.
Source
The component’s own files, exactly as they run on this site.
/** * CalendarGrid: one month as an ARIA grid. Every picker in this kit is built on it. * * const [focused, setFocused] = useState(todayISO()) * <CalendarGrid month={focused} focusedDate={focused} onFocusChange={setFocused} * value={date} onSelect={setDate} label="October 2026" /> * * Values are ISO dates ("2026-10-12"); see dates.ts for why. * * Keyboard (WAI-ARIA APG date picker): ← → day · ↑ ↓ week · Home / End first / last day of the * locale's week · PageUp / PageDown month · Shift+PageUp / PageDown year · Enter / Space choose. * Only `focusedDate` is in the Tab order (roving tabindex). The grid keeps no state: the parent * owns the focused date and the month on show, so two grids side by side can share one focus * (wrap them in an element with `data-calendar-root`). * * What people hear: weekday headers carry the full name; each date's label carries its state * ("Today, Monday, October 12, 2026, selected"), because VoiceOver ignores aria-selected on grid * cells (WebKit bug 276316). Today also gets aria-current="date". Unavailable dates stay * reachable, say "unavailable", and can't be chosen. *