On this pageOverview
Calendar
An accessible inline calendar grid built to the WAI-ARIA grid pattern. Calendar manages the 2D keyboard navigation state machine and renders a 6×7 grid of days with full screen reader support. Use it standalone for scheduling UIs and event calendars, or as the foundation of a date picker.
The calendar heading is a button: clicking it switches the day grid into a 3×4 months grid. Clicking the year heading from there switches into a paged 3×4 years grid (prev/next page through 12-year windows). Selecting a year drills back to the months grid for that year; selecting a month drills back to the days grid for that month.
Calendar uses the Submodel pattern: initialize with Calendar.init(), store the Model in your parent, delegate Messages via Calendar.update(), and render with Calendar.view(). The update function returns [Model, Commands, Option<OutMessage>]. The OutMessage lets parents react to meaningful events like date selection and month changes.
See it in an app
Check out how Calendar is wired up in a real Foldkit app.
A basic calendar with today highlighted. Click a day to select it, or tab into the grid and use the arrow keys. Navigation follows the full WAI-ARIA pattern including Home/End, PageUp/Down, and Shift+PageUp/Down for year jumps.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt — fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect, Match as M, Option } from 'effect'
import { Calendar, Command, Ui } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
// Add a field to your Model for the Calendar Submodel:
const Model = S.Struct({
calendarDemo: Ui.Calendar.Model,
// ...your other fields
})
// Fetch `today` once at the app boundary via flags so init stays pure:
const Flags = S.Struct({
today: Calendar.CalendarDate,
// ...your other flags
})
const flags = Effect.gen(function* () {
const today = yield* Calendar.today.local
return { today /* ...your other flags */ }
})
// In your init function, pass the flags-resolved today into Calendar.init:
const init = (flags: Flags) => [
{
calendarDemo: Ui.Calendar.init({
id: 'calendar-demo',
today: flags.today,
}),
// ...your other fields
},
[],
]
// Embed the Calendar Message in your parent Message for navigation and
// keyboard routing:
const GotCalendarMessage = m('GotCalendarMessage', {
message: Ui.Calendar.Message,
})
// Add your own domain Message for selection events. The Calendar view
// dispatches this via the `onSelectedDate` callback below — your update can
// validate, save, navigate, etc., then write the selection back into the
// Calendar's internal state.
const SelectedCalendarDate = m('SelectedCalendarDate', {
date: Calendar.CalendarDate,
})
// Inside your update function's M.tagsExhaustive({...}), handle the two
// paths. `GotCalendarMessage` delegates navigation, focus, and picker-mode
// transitions to the Calendar's own update:
GotCalendarMessage: ({ message }) => {
const [nextCalendar, commands] = Ui.Calendar.update(
model.calendarDemo,
message,
)
return [
evo(model, { calendarDemo: () => nextCalendar }),
commands.map(
Command.mapEffect(Effect.map(message => GotCalendarMessage({ message }))),
),
]
}
// `SelectedCalendarDate` is dispatched by the view's `onSelectedDate`
// callback when the user commits a date (click, Enter, or Space). Your
// handler runs domain side effects, then writes the selection back to
// Calendar via `Calendar.selectDate` so its internal cursor + selected
// cell stay in sync.
SelectedCalendarDate: ({ date }) => {
const [nextCalendar, commands] = Ui.Calendar.selectDate(
model.calendarDemo,
date,
)
return [
// Optionally store the selection in your own state too, e.g. for form
// submission or validation:
evo(model, {
calendarDemo: () =>
nextCalendar /*, pickedDate: () => Option.some(date) */,
}),
commands.map(
Command.mapEffect(Effect.map(message => GotCalendarMessage({ message }))),
),
]
}
// Inside your view function, render the calendar. The `onSelectedDate`
// callback converts a committed date into your parent Message. The `toView`
// callback receives a discriminated `CalendarAttributes` whose variant
// matches the calendar's current `viewMode` — pattern-match on `_tag` to
// render the day grid, the months grid, or the years grid:
const view = () => {
const h = html<Message>()
return Ui.Calendar.view({
model: model.calendarDemo,
toParentMessage: message => GotCalendarMessage({ message }),
onSelectedDate: date => SelectedCalendarDate({ date }),
toView: attributes =>
M.value(attributes).pipe(
M.tagsExhaustive({
Days: days =>
h.div(
[
...days.root,
h.Class('flex flex-col gap-3 rounded-xl border p-4'),
],
[
h.div(
[h.Class('flex items-center justify-between')],
[
h.button(
[...days.previousMonthButton, h.Class('rounded px-2')],
['‹'],
),
// The heading is a button: clicking it switches to the
// months grid for fast navigation. Pair the text with a
// chevron so the button reads as interactive at rest.
h.button(
[
h.Id(days.heading.id),
...days.headingButton,
h.Class(
'inline-flex items-center gap-2 rounded px-2 text-sm font-semibold',
),
],
[days.heading.text, ' ▾'],
),
h.button(
[...days.nextMonthButton, h.Class('rounded px-2')],
['›'],
),
],
),
h.div(
[...days.grid, h.Class('flex flex-col gap-1 outline-none')],
[
h.div(
[...days.headerRow, h.Class('grid grid-cols-7 gap-1')],
days.columnHeaders.map(header =>
h.div(
[
...header.attributes,
h.Class('text-center text-xs uppercase'),
],
[header.name],
),
),
),
...days.weeks.map(week =>
h.div(
[...week.attributes, h.Class('grid grid-cols-7 gap-1')],
week.cells.map(cell =>
h.div(
// `group` lets day buttons react to parent state
// via group-data-[today], group-data-[selected],
// etc.
[
...cell.cellAttributes,
h.Class('group flex items-center justify-center'),
],
[
h.button(
[
...cell.buttonAttributes,
h.Class(
'h-9 w-9 rounded-full text-sm group-data-[today]:ring-1 group-data-[selected]:bg-accent-600 group-data-[selected]:text-white group-data-[outside-month]:text-gray-400 group-data-[disabled]:opacity-40',
),
],
[cell.label],
),
],
),
),
),
),
],
),
],
),
// The months grid renders 12 cells (one per month). Clicking the
// heading again drills further into the years grid.
Months: months =>
h.div(
[
...months.root,
h.Class('flex flex-col gap-3 rounded-xl border p-4'),
],
[
h.div(
[h.Class('flex items-center justify-center')],
[
h.button(
[
h.Id(months.heading.id),
...months.headingButton,
h.Class(
'inline-flex items-center gap-2 rounded px-2 text-sm font-semibold',
),
],
[months.heading.text, ' ▾'],
),
],
),
h.div(
[
...months.grid,
h.Class('grid grid-cols-3 gap-1 outline-none'),
],
months.cells.map(cell =>
h.div(
[
...cell.cellAttributes,
h.Class('group flex items-center justify-center'),
],
[
h.button(
[
...cell.buttonAttributes,
h.Class(
'h-12 w-full rounded-md text-sm group-data-[selected]:bg-accent-600 group-data-[selected]:text-white group-data-[disabled]:opacity-40',
),
],
[cell.shortLabel],
),
],
),
),
),
],
),
// The years grid renders 12 cells (one paged window). Prev/next
// page through 12-year windows; clicking a year drills back to
// the months grid for that year.
Years: years =>
h.div(
[
...years.root,
h.Class('flex flex-col gap-3 rounded-xl border p-4'),
],
[
h.div(
[h.Class('flex items-center justify-between')],
[
h.button(
[...years.previousPageButton, h.Class('rounded px-2')],
['‹'],
),
h.h2(
[
h.Id(years.heading.id),
h.Class('text-sm font-semibold'),
],
[years.heading.text],
),
h.button(
[...years.nextPageButton, h.Class('rounded px-2')],
['›'],
),
],
),
h.div(
[
...years.grid,
h.Class('grid grid-cols-3 gap-1 outline-none'),
],
years.cells.map(cell =>
h.div(
[
...cell.cellAttributes,
h.Class('group flex items-center justify-center'),
],
[
h.button(
[
...cell.buttonAttributes,
h.Class(
'h-12 w-full rounded-md text-sm group-data-[selected]:bg-accent-600 group-data-[selected]:text-white group-data-[disabled]:opacity-40',
),
],
[cell.label],
),
],
),
),
),
],
),
}),
),
})
}Calendar is headless. Your toView callback controls all markup and styling. The attribute groups carry ARIA and event wiring; data attributes on day cells let you style state variants with CSS selectors like data-[today]: and group-data-[selected]:.
| Attribute | Condition |
|---|---|
data-today | Present on the cell representing "today" — the day cell in Days mode, the current month cell in Months mode, the current year cell in Years mode. |
data-selected | Present on the calendar's currently-centered cell — the selected date in Days mode, the centered month (viewMonth) in Months mode, the centered year (viewYear) in Years mode. |
data-focused | Present on the cell at the keyboard cursor position while the grid has DOM focus. |
data-outside-month | (Days mode only.) Present on cells that fall outside the currently-viewed month (leading/trailing grid rows). |
data-disabled | Present on cells disabled by min/max, disabledDaysOfWeek, or disabledDates. |
The grid container receives DOM focus, and navigation happens via aria-activedescendant. Screen readers announce the focused cell without moving browser focus. Disabled dates are skipped during navigation with a bounded cap so fully-disabled ranges terminate cleanly.
| Key | Description |
|---|---|
| ArrowLeft / ArrowRight | Move the focus cursor by one cell. Days: ±1 day. Months: ±1 month (wraps across years). Years: ±1 year (wraps across pages). |
| ArrowUp / ArrowDown | Move the focus cursor by one row. Days: ±1 week (7 days). Months: ±1 row (3 months). Years: ±1 row (3 years). |
| Home / End | (Days mode only.) Move focus to the start / end of the current week (based on locale.firstDayOfWeek). |
| PageUp / PageDown | Days: ±1 month. Months: ±1 year. Years: ±1 window (12 years). |
| Shift + PageUp / Shift + PageDown | (Days mode only.) Move focus by one year. |
| Enter / Space | Commit the focus cursor. Days: select the date. Months: jump the calendar to that month and drill back to Days. Years: jump to that year and drill back to Months. |
The grid renders with role="grid" and an explicit aria-label that leads with a non-numeric word ("Calendar, April 2026") so VoiceOver doesn't pattern-match the grid's row position into a date literal. Each row has role="row", column headers have role="columnheader", and day cells have role="gridcell" with aria-selected set on the chosen date. Day buttons carry a full accessible name via aria-label (e.g. "Monday, April 13, 2026"), and disabled days get aria-disabled="true".
Configuration object passed to Calendar.init().
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | Unique ID for the calendar instance. |
today | CalendarDate | - | The current calendar date. Typically fetched at the app boundary via Calendar.today.local and threaded through flags. |
initialSelectedDate | CalendarDate | - | Pre-selected date. When set, the view starts on the month containing this date. |
locale | LocaleConfig | defaultEnglishLocale | Month and day names plus the first day of the week. Import from foldkit/calendar. |
minDate | CalendarDate | - | Earliest selectable date. Dates before minDate are marked disabled and skipped by keyboard navigation. |
maxDate | CalendarDate | - | Latest selectable date. Dates after maxDate are marked disabled and skipped by keyboard navigation. |
disabledDaysOfWeek | ReadonlyArray<DayOfWeek> | [] | Days of the week to disable (e.g. ["Saturday", "Sunday"] for weekday-only selection). |
disabledDates | ReadonlyArray<CalendarDate> | [] | Explicit list of disabled dates (e.g. holidays). Pre-compute for complex rules. |
The calendar state managed as a Submodel field in your parent Model.
| Name | Type | Default | Description |
|---|---|---|---|
id | string | - | The calendar instance ID. |
today | CalendarDate | - | Cached "today" used for the data-today highlight and as the fallback focus target. |
viewYear | number | - | The year currently rendered in the grid. |
viewMonth | number | - | The month (1-12) currently rendered in the grid. |
maybeFocusedDate | Option<CalendarDate> | - | The keyboard cursor position, referenced by aria-activedescendant on the grid. |
maybeSelectedDate | Option<CalendarDate> | - | The committed selection. Distinct from maybeFocusedDate. Arrow keys never change selection. |
isGridFocused | boolean | - | Whether the grid container has DOM focus. Used to apply focused styling only when visually appropriate. |
locale | LocaleConfig | - | The locale for month/day names and first day of the week. |
maybeMinDate | Option<CalendarDate> | - | Lower bound for selectable dates. |
maybeMaxDate | Option<CalendarDate> | - | Upper bound for selectable dates. |
disabledDaysOfWeek | ReadonlyArray<DayOfWeek> | - | Days of the week disabled across every month. |
disabledDates | ReadonlyArray<CalendarDate> | - | Explicit dates marked as disabled. |
Configuration object passed to Calendar.view().
| Name | Type | Default | Description |
|---|---|---|---|
model | Calendar.Model | - | The calendar state from your parent Model. |
toParentMessage | (childMessage: Calendar.Message) => ParentMessage | - | Wraps Calendar Messages in your parent Message type for Submodel delegation (navigation, keyboard, picker-mode transitions). |
onSelectedDate | (date: CalendarDate) => ParentMessage | - | Optional. When provided, click / Enter / Space on a day dispatches this callback directly (controlled mode: parent owns the event). When omitted, the calendar manages its own maybeSelectedDate automatically (uncontrolled mode). In controlled mode, use Calendar.selectDate(model, date) to write the selection back to internal state. |
toView | (attributes: CalendarAttributes) => Html | - | Callback that receives a discriminated CalendarAttributes whose variant matches the calendar viewMode (Days, Months, or Years). Pattern-match on _tag to render each grid. |
previousMonthLabel | string | 'Previous month' | Accessible label for the previous-month navigation button (Days mode). |
nextMonthLabel | string | 'Next month' | Accessible label for the next-month navigation button (Days mode). |
previousYearsPageLabel | string | 'Previous 12 years' | Accessible label for the previous-page button in the years grid. |
nextYearsPageLabel | string | 'Next 12 years' | Accessible label for the next-page button in the years grid. |
daysHeadingButtonLabel | string | 'Switch to month picker' | Accessible label for the heading button in Days mode. Clicked to drill into the months grid. |
monthsHeadingButtonLabel | string | 'Switch to year picker' | Accessible label for the heading button in Months mode. Clicked to drill into the years grid. |
Attribute groups and derived data provided to the toView callback.
| Name | Type | Default | Description |
|---|---|---|---|
_tag | 'Days' | 'Months' | 'Years' | - | Discriminator matching model.viewMode. Use M.tagsExhaustive to render each variant. The fields below describe the union of variants — only the fields documented for the current _tag are present. |
root | ReadonlyArray<Attribute<Message>> | - | (All modes.) Spread onto the outermost wrapper. Includes the root id. |
grid | ReadonlyArray<Attribute<Message>> | - | (All modes.) Spread onto the grid container. Includes role="grid", tabindex, aria-label, aria-activedescendant, and keyboard/focus handlers. |
heading | { id: string; text: string } | - | (All modes.) Heading id and text. In Days mode the text is "September 2019"; in Months mode "2019"; in Years mode "2016–2027" (the visible window). |
headingButton | ReadonlyArray<Attribute<Message>> | - | (Days, Months only.) Spread onto a <button> wrapping heading.text. Clicking dispatches ClickedHeading and drills one level deeper. Years mode is terminal and omits this field. |
previousMonthButton / nextMonthButton | ReadonlyArray<Attribute<Message>> | - | (Days only.) Prev/next month navigation. Click handlers dispatch ClickedPreviousMonthButton / ClickedNextMonthButton. |
previousPageButton / nextPageButton | ReadonlyArray<Attribute<Message>> | - | (Years only.) Page through 12-year windows. Click handlers dispatch PagedYears with direction -1 or 1. |
headerRow / columnHeaders | Attribute<Message>[] + ColumnHeader<Message>[] | - | (Days only.) Row attributes (role="row") and seven column headers (role="columnheader") in locale-aware order. |
weeks | ReadonlyArray<Week<Message>> | - | (Days only.) Six week rows. Each Week carries its own row attributes (role="row", aria-rowindex) and seven DayCells. DayCells carry cellAttributes (role="gridcell", aria-colindex), buttonAttributes (type="button", aria-label, click), the day label string, and state flags (isToday, isSelected, isFocused, isInViewMonth, isDisabled). |
cells | ReadonlyArray<MonthCell<Message>> | ReadonlyArray<YearCell<Message>> | - | (Months, Years.) Twelve cells. In Months mode each cell carries the month number (1-12), the full localized name (label, e.g. "September"), and the localized abbreviation (shortLabel, e.g. "Sep") — render whichever fits, never substring label to abbreviate. In Years mode each cell carries a year from the current 12-year window. Both expose cellAttributes (role="gridcell", aria-selected), buttonAttributes (click dispatches SelectedMonth/SelectedYear), and state flags (isSelected, isFocused, isCurrentMonth/isCurrentYear, isDisabled). |
Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Parents pattern-match on the OutMessage in their own update handler.
| Name | Type | Default | Description |
|---|---|---|---|
ChangedViewMonth | { year: number; month: number } | - | Emitted when navigation changes the visible month (prev/next buttons, heading-drill selection of a different month, or arrow keys crossing a month boundary, or a commit that crosses a month). Useful for inline-calendar consumers loading month-scoped data like holidays or availability. Note: date selection does NOT go through OutMessage. Consumers subscribe via the onSelectedDate ViewConfig callback (see above). |
Helpers you call from your own update handlers to drive the calendar imperatively: writing back the selection in controlled mode, focusing the grid, or updating constraints when they derive from other Model state.
The four set* helpers are how you implement cross-field date validation. Constraints are set at init time and updated via these helpers. They do not live on ViewConfig, because the update function needs them for keyboard-navigation disabled-skipping and commit-time validation. For an end date that must be on or after a start date, call setMinDate(endCalendar, startCalendar.maybeSelectedDate) in the handler that processes the start date change.
| Name | Type | Default | Description |
|---|---|---|---|
selectDate | (model: Model, date: CalendarDate) => [Model, Commands, Option<OutMessage>] | - | Commits the given date and moves the cursor onto it. Use in controlled mode (when ViewConfig provides onSelectedDate) to write the selection back to the calendar. |
focusGrid | (modelId: string) => Command | - | Returns a command that focuses the calendar grid container. Parent components like DatePicker use this to hand focus to the grid's keyboard layer after opening. |
dropToDays | (model: Model) => Model | - | Returns the calendar to Days mode regardless of current depth (Days, Months, or Years). Useful for standalone consumers that want to wire their own back-out gesture; popovered consumers like DatePicker call this internally on open and close so the picker always reopens at the day grid. |
setMinDate | (model: Model, maybeMinDate: Option<CalendarDate>) => Model | - | Updates the minimum selectable date. Pass Option.none() to remove the minimum. Use for cross-field validation when the minimum derives from other Model state. Does not reconcile the current selection if it falls below the new minimum. |
setMaxDate | (model: Model, maybeMaxDate: Option<CalendarDate>) => Model | - | Updates the maximum selectable date. Pass Option.none() to remove the maximum. Does not reconcile the current selection. |
setDisabledDates | (model: Model, disabledDates: ReadonlyArray<CalendarDate>) => Model | - | Replaces the list of individually-disabled dates (e.g. holidays). Pass an empty array to clear. |
setDisabledDaysOfWeek | (model: Model, disabledDaysOfWeek: ReadonlyArray<DayOfWeek>) => Model | - | Replaces the list of disabled days of the week (e.g. ["Saturday", "Sunday"]). Pass an empty array to clear. |