Skip to main content
On this pageOverview

Date Picker

Overview

An accessible date picker that wraps Calendar in a Popover. Consumers provide the trigger button face and the calendar grid layout. DatePicker handles focus choreography (opening focuses the grid, closing returns focus to the trigger), open/close state, and an optional hidden form input for native form submission.

DatePicker uses the Submodel pattern: initialize with DatePicker.init(), store the Model in your parent, delegate Messages via DatePicker.update(), and render with DatePicker.view(). The update function returns [Model, Commands, Option<OutMessage>]. The OutMessage forwards the embedded calendar's ChangedViewMonth so consumers can react to month-scoped data needs. Date selection goes through the onSelectedDate ViewConfig callback, not OutMessage. For programmatic control in update functions, use DatePicker.open(model) and DatePicker.close(model) which return [Model, Commands] directly.

The calendar heading inside the popover is a button: clicking it switches the day grid into a 3x4 months grid; clicking the year heading from there switches into a paged 3x4 years grid. Selecting a year drills back to the months grid for that year; selecting a month drills back to the days grid for that month. Re-opening the popover always shows the day grid.

See it in an app

Check out how DatePicker is wired up in a real Foldkit app.

Examples

A date picker constrained to a one-year window around today via minDate and maxDate. Click the trigger to open, pick a date, click the heading to drill into a months grid (and again to drill into a years grid), or navigate with the full WAI-ARIA grid keyboard pattern. Press Enter to commit, Escape to dismiss.

// 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 DatePicker Submodel:
const Model = S.Struct({
  datePickerDemo: Ui.DatePicker.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 DatePicker.init.
// Optional: constrain the selectable range with minDate / maxDate.
const init = (flags: Flags) => [
  {
    datePickerDemo: Ui.DatePicker.init({
      id: 'date-picker-demo',
      today: flags.today,
      minDate: flags.today,
      maxDate: Calendar.addMonths(flags.today, 3),
    }),
    // ...your other fields
  },
  [],
]

// Embed the DatePicker Message in your parent Message. DatePicker handles
// Calendar + Popover routing internally — you only need one wrapper:
const GotDatePickerMessage = m('GotDatePickerMessage', {
  message: Ui.DatePicker.Message,
})

// Add a domain Message for selection events. The DatePicker view dispatches
// this via the `onSelectedDate` callback — your update handler decides what
// to do with the date (validate, save, navigate, etc.), then writes it back
// via `DatePicker.selectDate` to sync internal state.
const SelectedDate = m('SelectedDate', {
  date: Calendar.CalendarDate,
})

// Inside your update function's M.tagsExhaustive({...}), handle both paths.
// `GotDatePickerMessage` delegates navigation, focus, and popover messages
// to DatePicker's own update:
GotDatePickerMessage: ({ message }) => {
  const [nextDatePicker, commands] = Ui.DatePicker.update(
    model.datePickerDemo,
    message,
  )

  return [
    evo(model, { datePickerDemo: () => nextDatePicker }),
    commands.map(
      Command.mapEffect(
        Effect.map(message => GotDatePickerMessage({ message })),
      ),
    ),
  ]
}

// `SelectedDate` is dispatched by the view's `onSelectedDate` callback when
// the user commits a date. Write the selection back to DatePicker via
// `DatePicker.selectDate` so its internal state stays in sync:
SelectedDate: ({ date }) => {
  const [nextDatePicker, commands] = Ui.DatePicker.selectDate(
    model.datePickerDemo,
    date,
  )

  return [
    evo(model, { datePickerDemo: () => nextDatePicker }),
    commands.map(
      Command.mapEffect(
        Effect.map(message => GotDatePickerMessage({ message })),
      ),
    ),
  ]
}

// Inside your view function, render the date picker. The `onSelectedDate`
// callback converts a committed date into your parent Message. The
// `toCalendarView` callback receives the same discriminated
// `CalendarAttributes` as Calendar.view's `toView`. Pattern-match on
// `_tag` to render the day grid, the months grid, or the years grid:
const view = () => {
  const h = html<Message>()

  return Ui.DatePicker.view({
    model: model.datePickerDemo,
    toParentMessage: message => GotDatePickerMessage({ message }),
    onSelectedDate: date => SelectedDate({ date }),
    anchor: { placement: 'bottom-start', gap: 4, padding: 8 },
    triggerContent: maybeDate =>
      Option.match(maybeDate, {
        onNone: () => h.span([], ['Pick a date']),
        onSome: date => h.span([], [`${date.year}-${date.month}-${date.day}`]),
      }),
    toCalendarView: attributes =>
      M.value(attributes).pipe(
        M.tagsExhaustive({
          Days: days =>
            h.div(
              [...days.root, h.Class('flex flex-col gap-3 p-4')],
              [
                h.div(
                  [h.Class('flex items-center justify-between')],
                  [
                    h.button(
                      [...days.previousMonthButton, h.Class('rounded px-2')],
                      ['‹'],
                    ),
                    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(
                            [
                              ...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 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 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],
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
        }),
      ),
    // Optional: enable hidden form input for native <form> submission:
    name: 'appointment-date',
  })
}

Styling

DatePicker is headless. You control the trigger button via triggerContent and triggerClassName, the popover panel via panelClassName, and the calendar grid via the toCalendarView callback. Data attributes on day cells let you style state variants with CSS selectors like group-data-[selected]: and group-data-[disabled]:.

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.
data-openPresent on the trigger button and wrapper while the popover is open.

Keyboard Interaction

The trigger button opens the popover on Enter, Space, or ArrowDown. Inside the popover, the calendar grid handles the full WAI-ARIA grid keyboard pattern. Escape closes the popover from both the trigger and the grid.

KeyDescription
Enter / Space / ArrowDownOpen the popover when the trigger button is focused.
EscapeClose the popover from the trigger button or from inside the calendar grid.
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 and close the popover. Months: jump the calendar to that month and drill back to Days. Years: jump to that year and drill back to Months.

Accessibility

The trigger button uses aria-expanded and aria-controls to announce the popover relationship. Inside the popover, the calendar grid renders with role="grid" and an explicit aria-label that leads with a non-numeric word ("Calendar, April 2026") so VoiceOver does not pattern-match the grid's row position into a date literal. aria-activedescendant tracks the keyboard cursor; rows carry role="row" with aria-rowindex; cells carry role="gridcell", aria-colindex, and aria-selected on the chosen date. Day buttons carry full accessible names via aria-label and disabled days get aria-disabled="true". When a hidden form input is enabled via the name prop, the selected date is encoded as an ISO string (YYYY-MM-DD) for native form submission.

API Reference

InitConfig

Configuration object passed to DatePicker.init(). Calendar constraints (min/max, disabled dates) are forwarded to the embedded Calendar submodel.

NameTypeDefaultDescription
idstring-Unique ID for the date picker 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.
isAnimatedbooleanfalseEnables animation coordination on the popover panel (enter/leave animations).
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 DatePicker Model. Stored on your parent Model and threaded through DatePicker.update() and DatePicker.view().

NameTypeDefaultDescription
idstring-The date picker instance ID.
maybeSelectedDateOption<CalendarDate>-The committed selection. In uncontrolled mode, the date picker manages this automatically when the user picks a date. In controlled mode, the parent owns the value and writes it back via DatePicker.selectDate.
calendarCalendar.Model-The embedded Calendar submodel. Forwards navigation, focus, locale, and disabled-cell state. The picker delegates Calendar messages and resets the calendar to Days mode every time the popover opens or closes.
popoverPopover.Model-The embedded Popover submodel. Tracks open/close state, animation phase, and focus choreography (opening focuses the calendar grid, closing returns focus to the trigger).

ViewConfig

Configuration object passed to DatePicker.view().

NameTypeDefaultDescription
modelDatePicker.Model-The date picker state from your parent Model.
toParentMessage(message: DatePicker.Message) => ParentMessage-Wraps DatePicker Messages in your parent Message type for Submodel delegation.
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, DatePicker manages its own maybeSelectedDate automatically (uncontrolled mode). In controlled mode, use DatePicker.selectDate(model, date) to write the selection back to internal state.
anchorAnchorConfig-Popover positioning config (placement, gap, offset, padding). Controls where the calendar panel floats relative to the trigger.
triggerContent(maybeDate: Option<CalendarDate>) => Html-Renders the trigger button face. Receives the current selection so you can show the formatted date or a placeholder.
toCalendarView(attributes: CalendarAttributes<ParentMessage>) => Html-Renders the calendar grid layout inside the popover panel. Same callback shape as Calendar.view toView. Lay out the attribute groups (grid, header, weeks, cells) however you like.
isDisabledbooleanfalseDisables the trigger button, preventing the popover from opening.
namestring-When provided, renders a hidden <input> with this name and the selected date encoded as an ISO string (YYYY-MM-DD) for native form submission.
triggerClassName / triggerAttributesstring / ReadonlyArray<Attribute<Message>>-Class name and additional attributes spread onto the trigger button.
panelClassName / panelAttributesstring / ReadonlyArray<Attribute<Message>>-Class name and additional attributes spread onto the popover panel.
backdropClassName / backdropAttributesstring / ReadonlyArray<Attribute<Message>>-Class name and additional attributes spread onto the click-outside backdrop.

CalendarAttributes

The discriminated union passed to toCalendarView. Pattern-match on _tag ('Days' | 'Months' | 'Years') with M.tagsExhaustive to render each grid. Each variant exposes a different shape: Days carries weeks plus a headingButton; Months carries 12 month cells plus a headingButton; Years carries 12 year cells plus prev/next page buttons. See the Calendar page's CalendarAttributes section for the full prop table — the type is the same.

OutMessage

Messages emitted to the parent through the third element of [Model, Commands, Option<OutMessage>]. Pattern-match on the OutMessage in your update handler.

NameTypeDefaultDescription
ChangedViewMonth{ year: number; month: number }-Emitted when navigation changes the visible month inside the calendar grid. Date selection goes through the onSelectedDate ViewConfig callback, not OutMessage.

Programmatic Helpers

Helpers you call from your own update handlers to drive the date picker imperatively: for writing back the selection in controlled mode, opening/closing on domain events, 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(endDate, startDate.maybeSelectedDate) in the handler that processes the start date change.

NameTypeDefaultDescription
selectDate(model: Model, date: CalendarDate) => [Model, Commands]-Commits the given date and closes the popover. Use in controlled mode (when ViewConfig provides onSelectedDate) to write the selection back to the date picker.
clear(model: Model) => [Model, Commands]-Clears the selected date. Does not close the popover.
open(model: Model) => [Model, Commands]-Programmatically opens the popover. Use from domain-event handlers when the date picker should open in response to something other than a trigger click.
close(model: Model) => [Model, Commands]-Programmatically closes the popover.
setMinDate(model: Model, maybeMinDate: Option<CalendarDate>) => Model-Updates the minimum selectable date. Pass Option.none() to remove the minimum. Use for cross-field validation, e.g. an end date picker whose minimum tracks a start date picker's selection. 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