Skip to main content
On this pageOverview

Calendar

Overview

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.

Examples

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.

Sun
Mon
Tue
Wed
Thu
Fri
Sat
// 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],
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
        }),
      ),
  })
}

Styling

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]:.

AttributeCondition
data-todayPresent 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-selectedPresent 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-focusedPresent 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-disabledPresent on cells disabled by min/max, disabledDaysOfWeek, or disabledDates.

Keyboard Interaction

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.

KeyDescription
ArrowLeft / ArrowRightMove the focus cursor by one cell. Days: ±1 day. Months: ±1 month (wraps across years). Years: ±1 year (wraps across pages).
ArrowUp / ArrowDownMove 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 / PageDownDays: ±1 month. Months: ±1 year. Years: ±1 window (12 years).
Shift + PageUp / Shift + PageDown(Days mode only.) Move focus by one year.
Enter / SpaceCommit 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.

Accessibility

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".

API Reference

InitConfig

Configuration object passed to Calendar.init().

NameTypeDefaultDescription
idstring-Unique ID for the calendar instance.
todayCalendarDate-The current calendar date. Typically fetched at the app boundary via Calendar.today.local and threaded through flags.
initialSelectedDateCalendarDate-Pre-selected date. When set, the view starts on the month containing this date.
localeLocaleConfigdefaultEnglishLocaleMonth and day names plus the first day of the week. Import from foldkit/calendar.
minDateCalendarDate-Earliest selectable date. Dates before minDate are marked disabled and skipped by keyboard navigation.
maxDateCalendarDate-Latest selectable date. Dates after maxDate are marked disabled and skipped by keyboard navigation.
disabledDaysOfWeekReadonlyArray<DayOfWeek>[]Days of the week to disable (e.g. ["Saturday", "Sunday"] for weekday-only selection).
disabledDatesReadonlyArray<CalendarDate>[]Explicit list of disabled dates (e.g. holidays). Pre-compute for complex rules.

Model

The calendar state managed as a Submodel field in your parent Model.

NameTypeDefaultDescription
idstring-The calendar instance ID.
todayCalendarDate-Cached "today" used for the data-today highlight and as the fallback focus target.
viewYearnumber-The year currently rendered in the grid.
viewMonthnumber-The month (1-12) currently rendered in the grid.
maybeFocusedDateOption<CalendarDate>-The keyboard cursor position, referenced by aria-activedescendant on the grid.
maybeSelectedDateOption<CalendarDate>-The committed selection. Distinct from maybeFocusedDate. Arrow keys never change selection.
isGridFocusedboolean-Whether the grid container has DOM focus. Used to apply focused styling only when visually appropriate.
localeLocaleConfig-The locale for month/day names and first day of the week.
maybeMinDateOption<CalendarDate>-Lower bound for selectable dates.
maybeMaxDateOption<CalendarDate>-Upper bound for selectable dates.
disabledDaysOfWeekReadonlyArray<DayOfWeek>-Days of the week disabled across every month.
disabledDatesReadonlyArray<CalendarDate>-Explicit dates marked as disabled.

ViewConfig

Configuration object passed to Calendar.view().

NameTypeDefaultDescription
modelCalendar.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.
previousMonthLabelstring'Previous month'Accessible label for the previous-month navigation button (Days mode).
nextMonthLabelstring'Next month'Accessible label for the next-month navigation button (Days mode).
previousYearsPageLabelstring'Previous 12 years'Accessible label for the previous-page button in the years grid.
nextYearsPageLabelstring'Next 12 years'Accessible label for the next-page button in the years grid.
daysHeadingButtonLabelstring'Switch to month picker'Accessible label for the heading button in Days mode. Clicked to drill into the months grid.
monthsHeadingButtonLabelstring'Switch to year picker'Accessible label for the heading button in Months mode. Clicked to drill into the years grid.

CalendarAttributes

Attribute groups and derived data provided to the toView callback.

NameTypeDefaultDescription
_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.
rootReadonlyArray<Attribute<Message>>-(All modes.) Spread onto the outermost wrapper. Includes the root id.
gridReadonlyArray<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).
headingButtonReadonlyArray<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 / nextMonthButtonReadonlyArray<Attribute<Message>>-(Days only.) Prev/next month navigation. Click handlers dispatch ClickedPreviousMonthButton / ClickedNextMonthButton.
previousPageButton / nextPageButtonReadonlyArray<Attribute<Message>>-(Years only.) Page through 12-year windows. Click handlers dispatch PagedYears with direction -1 or 1.
headerRow / columnHeadersAttribute<Message>[] + ColumnHeader<Message>[]-(Days only.) Row attributes (role="row") and seven column headers (role="columnheader") in locale-aware order.
weeksReadonlyArray<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).
cellsReadonlyArray<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).

OutMessage

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.

NameTypeDefaultDescription
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).

Programmatic Helpers

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.

NameTypeDefaultDescription
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.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson