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.
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.
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.
April 2026
// 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 { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { Class, Id, button, div, h2 } from './html'
// 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 dropdown
// messages to the Calendar's own update:
GotCalendarMessage: ({ message }) => {
const [nextCalendar, commands, maybeOutMessage] = Ui.Calendar.update(
model.calendarDemo,
message,
)
const mappedCommands = commands.map(
Command.mapEffect(Effect.map(message => GotCalendarMessage({ message }))),
)
// Exhaustive dispatch on the Calendar OutMessage — currently only
// `ChangedViewMonth` fires from here (useful for loading month-scoped
// data like holidays or availability).
const additionalCommands = Option.match(maybeOutMessage, {
onNone: () => [],
onSome: M.type<Ui.Calendar.OutMessage>().pipe(
M.tagsExhaustive({
ChangedViewMonth: ({ year, month }) => {
// Load holidays, events, availability for the newly-visible month.
return []
},
}),
),
})
return [
evo(model, { calendarDemo: () => nextCalendar }),
[...mappedCommands, ...additionalCommands],
]
}
// `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 attribute groups (grid, rows, buttons, dropdowns) plus
// the 6×7 grid of day cells to lay out however you like:
Ui.Calendar.view({
model: model.calendarDemo,
toParentMessage: message => GotCalendarMessage({ message }),
onSelectedDate: date => SelectedCalendarDate({ date }),
toView: attributes =>
div(
[...attributes.root, Class('flex flex-col gap-3 rounded-xl border p-4')],
[
div(
[Class('flex items-center justify-between')],
[
button(
[...attributes.previousMonthButton, Class('rounded px-2')],
['‹'],
),
h2(
[Id(attributes.heading.id), Class('text-sm font-semibold')],
[attributes.heading.text],
),
button(
[...attributes.nextMonthButton, Class('rounded px-2')],
['›'],
),
],
),
div(
[...attributes.grid, Class('flex flex-col gap-1 outline-none')],
[
div(
[...attributes.headerRow, Class('grid grid-cols-7 gap-1')],
attributes.columnHeaders.map(header =>
div(
[
...header.attributes,
Class('text-center text-xs uppercase'),
],
[header.name],
),
),
),
...attributes.weeks.map(week =>
div(
[...week.attributes, Class('grid grid-cols-7 gap-1')],
week.cells.map(cell =>
div(
// `group` lets day buttons react to parent state via
// group-data-[today], group-data-[selected], etc.
[
...cell.cellAttributes,
Class('group flex items-center justify-center'),
],
[
button(
[
...cell.buttonAttributes,
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],
),
],
),
),
),
),
],
),
],
),
})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 for today. |
data-selected | Present on the cell for the selected date. |
data-focused | Present on the cell for the keyboard cursor position while the grid has DOM focus. |
data-outside-month | 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 focus by one day. |
| ArrowUp / ArrowDown | Move focus by one week. |
| Home / End | Move focus to the start / end of the current week (based on locale.firstDayOfWeek). |
| PageUp / PageDown | Move focus by one month. |
| Shift + PageUp / Shift + PageDown | Move focus by one year. |
| Enter / Space | Commit the focused date as the selection. |
The grid renders with role="grid" and is labelled by the month/year heading via aria-labelledby. 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. |
maybeInitialSelectedDate | Option<CalendarDate> | Option.none() | 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. |
maybeMinDate | Option<CalendarDate> | Option.none() | Earliest selectable date. Dates before minDate are marked disabled and skipped by keyboard navigation. |
maybeMaxDate | Option<CalendarDate> | Option.none() | 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, dropdown changes). |
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. Matches the Listbox / Combobox / Popover callback pattern. |
toView | (attributes: CalendarAttributes) => Html | - | Callback that receives attribute groups plus derived grid data (weeks, column headers, dropdown options) to render the calendar layout. |
previousMonthLabel | string | 'Previous month' | Accessible label for the previous-month navigation button. |
nextMonthLabel | string | 'Next month' | Accessible label for the next-month navigation button. |
monthSelectLabel | string | 'Select month' | Accessible label for the month dropdown. |
yearSelectLabel | string | 'Select year' | Accessible label for the year dropdown. |
Attribute groups and derived data provided to the toView callback.
| Name | Type | Default | Description |
|---|---|---|---|
root | ReadonlyArray<Attribute<Message>> | - | Spread onto the outermost wrapper. Includes the root id. |
grid | ReadonlyArray<Attribute<Message>> | - | Spread onto the grid container. Includes role="grid", tabindex, aria-labelledby, aria-activedescendant, and keyboard/focus handlers. |
heading | { id: string; text: string } | - | Month/year heading text plus an id for aria-labelledby wiring. Render inside an element with Id(heading.id). |
previousMonthButton | ReadonlyArray<Attribute<Message>> | - | Spread onto the previous-month button. Includes aria-label and click handler. |
nextMonthButton | ReadonlyArray<Attribute<Message>> | - | Spread onto the next-month button. Includes aria-label and click handler. |
monthSelect / monthOptions | Attribute<Message>[] + { value; label }[] | - | Attributes and option data for an optional month dropdown. Pair with a native <select>. |
yearSelect / yearOptions | Attribute<Message>[] + number[] | - | Attributes and year list for an optional year dropdown. Range derived from min/max or today ±10/100. |
headerRow / columnHeaders | Attribute<Message>[] + ColumnHeader<Message>[] | - | Row attributes (role="row") and seven column headers (role="columnheader") in locale-aware order. |
weeks | ReadonlyArray<Week<Message>> | - | 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). |
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 (buttons, dropdowns, 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). |