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 fires when the user commits a date or clears the selection.

Examples

A date picker constrained to the next three months via maybeMinDate and maybeMaxDate. Click the trigger to open, pick a date or navigate with arrow keys, then press Enter to commit or 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 { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Class, Id, button, div, h2, span } from './html'

// 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 lays out the calendar grid — same shape as
// Calendar.view's `toView`:
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: () => span([], ['Pick a date']),
      onSome: date => span([], [`${date.year}-${date.month}-${date.day}`]),
    }),
  toCalendarView: attributes =>
    div(
      [...attributes.root, Class('flex flex-col gap-3 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(
                    [
                      ...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],
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ],
        ),
      ],
    ),
  // 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 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.
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 focus by one day inside the calendar grid.
ArrowUp / ArrowDownMove focus by one week inside the calendar grid.
Home / EndMove focus to the start / end of the current week (based on locale.firstDayOfWeek).
PageUp / PageDownMove focus by one month inside the calendar grid.
Shift + PageUp / Shift + PageDownMove focus by one year inside the calendar grid.
Enter / SpaceCommit the focused date as the selection and close the popover.

Accessibility

The trigger button uses aria-expanded and aria-controls to announce the popover relationship. Inside the popover, the calendar grid renders with the full WAI-ARIA grid pattern: role="grid" with aria-activedescendant for cursor tracking, role="row" and role="gridcell" with 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 calendar grid starts on the month containing this date.
isAnimatedbooleanfalseEnables CSS transition 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.

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, committing a date dispatches this callback directly (controlled mode). When omitted, DatePicker manages its own selection (uncontrolled mode). In controlled mode, use DatePicker.selectDate(model, date) to write the selection back.
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.

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.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson