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.

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.

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.

April 2026

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 { 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],
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ],
        ),
      ],
    ),
})

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 for today.
data-selectedPresent on the cell for the selected date.
data-focusedPresent on the cell for the keyboard cursor position while the grid has DOM focus.
data-outside-monthPresent 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 focus by one day.
ArrowUp / ArrowDownMove focus by one week.
Home / EndMove focus to the start / end of the current week (based on locale.firstDayOfWeek).
PageUp / PageDownMove focus by one month.
Shift + PageUp / Shift + PageDownMove focus by one year.
Enter / SpaceCommit the focused date as the selection.

Accessibility

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

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.
maybeInitialSelectedDateOption<CalendarDate>Option.none()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.
maybeMinDateOption<CalendarDate>Option.none()Earliest selectable date. Dates before minDate are marked disabled and skipped by keyboard navigation.
maybeMaxDateOption<CalendarDate>Option.none()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, 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.
previousMonthLabelstring'Previous month'Accessible label for the previous-month navigation button.
nextMonthLabelstring'Next month'Accessible label for the next-month navigation button.
monthSelectLabelstring'Select month'Accessible label for the month dropdown.
yearSelectLabelstring'Select year'Accessible label for the year dropdown.

CalendarAttributes

Attribute groups and derived data provided to the toView callback.

NameTypeDefaultDescription
rootReadonlyArray<Attribute<Message>>-Spread onto the outermost wrapper. Includes the root id.
gridReadonlyArray<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).
previousMonthButtonReadonlyArray<Attribute<Message>>-Spread onto the previous-month button. Includes aria-label and click handler.
nextMonthButtonReadonlyArray<Attribute<Message>>-Spread onto the next-month button. Includes aria-label and click handler.
monthSelect / monthOptionsAttribute<Message>[] + { value; label }[]-Attributes and option data for an optional month dropdown. Pair with a native <select>.
yearSelect / yearOptionsAttribute<Message>[] + number[]-Attributes and year list for an optional year dropdown. Range derived from min/max or today ±10/100.
headerRow / columnHeadersAttribute<Message>[] + ColumnHeader<Message>[]-Row attributes (role="row") and seven column headers (role="columnheader") in locale-aware order.
weeksReadonlyArray<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).

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

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson