Styled inline calendar grid with locale support, built on the @foldkit/ui Calendar submodel.

Preview
Style
Sun
Mon
Tue
Wed
Thu
Fri
Sat
src/demo/views/calendar.ts79 lines
import { Update } from 'foldkit'
import { Calendar as FoldkitCalendar } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as calendar from '../../generated/registry/ui/calendar'

import { DEMO_TODAY } from '../bundles'
import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotCalendarMessage: { message: calendar.Message },
})

// Single-date calendar mirroring apps/v4/examples/base/calendar-demo.tsx
export const calendarView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.submodel({
    slotId: model.calendar.id,
    model: model.calendar,
    view: calendar.view,
    viewInputs: calendar.styledViewInputs(
      { maybeSelectedDate: model.maybeSelectedDate, containerClass: 'rounded-lg border' },
      h,
    ),
    toParentMessage: (message) => Message.GotCalendarMessage({ message }),
  })

const foldCalendarOutMessage = M.type<calendar.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    SelectedDate:
      ({ date }) =>
      (model) => ({ model: evo(model, { maybeSelectedDate: () => Option.some(date) }) }),
    ChangedViewMonth: () => (model) => ({ model }),
  }),
)

const foldCalendar = Update.foldChild({
  update: calendar.update,
  read: (model: State) => Option.some(model.calendar),
  write: (model, next) => evo(model, { calendar: () => next }),
  toParentMessage: (message) => Message.GotCalendarMessage({ message }),
  foldOutMessage: foldCalendarOutMessage,
})

const fields = {
  calendar: calendar.Model,
  maybeSelectedDate: S.Option(FoldkitCalendar.CalendarDate),
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    calendar: calendar.init({
      id: 'calendar-demo',
      today: DEMO_TODAY,
      minDate: FoldkitCalendar.subtractYears(DEMO_TODAY, 1),
      maxDate: FoldkitCalendar.addYears(DEMO_TODAY, 1),
    }),
    maybeSelectedDate: Option.some(DEMO_TODAY),
  },
  messages: [Message.GotCalendarMessage],
  handlers: (model: State) => ({
    GotCalendarMessage: (payload: typeof Message.GotCalendarMessage.Type): UpdateReturn =>
      foldCalendar(model, payload.message),
  }),
  samples: [],
  // Date selection flows through the submodel's out-messages; the public
  // @foldkit/ui namespace exports no child-message constructors, so there
  // are no top-level samples to feed update().
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/calendar

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/calendar.ts283 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Calendar from '@/components/ui/calendar'`
 */
import { Match as M, Option } from 'effect'
import { Calendar as FoldkitCalendar } from '@foldkit/ui'
import type { CalendarDate } from 'foldkit/calendar'
import type { ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'

import { icon } from '@/lib/icons'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide'
import { cn } from '@/lib/utils'

// Re-export the @foldkit/ui Calendar submodel surface.

export const init = FoldkitCalendar.init
export const update = FoldkitCalendar.update
export const view = FoldkitCalendar.view
export const Model = FoldkitCalendar.Model
export type Model = typeof Model.Type
export const Message = FoldkitCalendar.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitCalendar.OutMessage
export type OutMessage = typeof OutMessage.Type

export type InitConfig = FoldkitCalendar.InitConfig
export type ViewInputs = FoldkitCalendar.ViewInputs
export type CalendarAttributes = FoldkitCalendar.CalendarAttributes
export type DaysModeAttributes = FoldkitCalendar.DaysModeAttributes
export type MonthsModeAttributes = FoldkitCalendar.MonthsModeAttributes
export type YearsModeAttributes = FoldkitCalendar.YearsModeAttributes
export type Week = FoldkitCalendar.Week

// foldcn gaps vs upstream: Days/Months/Years drill navigation instead of
// dropdown captions, single-date selection only (no ranges/week numbers),
// and state hooks ride on the cell's group data attrs (data-today/
// data-selected/data-focused/data-outside-month/data-disabled) rather than
// react-day-picker modifiers. foldkit emits those attrs empty (`data-x=""`),
// so the hooks match on attribute presence — never `[data-x=true]` like
// upstream's focused rules — and the cell mounts a plain `group` alongside
// upstream's named `group/day` for the unscooped variants to key off.

/** Upstream root + months strings combined (foldcn renders one container). */
export const calendarContainerClass =
  'p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] group/calendar relative flex w-[calc(var(--cell-size)*7+var(--spacing)*12)] flex-col gap-4 bg-background select-none in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent'

/** Upstream nav + month_caption anatomy (foldcn keeps the header in flow). */
export const calendarHeaderClass =
  'grid h-(--cell-size) w-full grid-cols-[var(--cell-size)_1fr_var(--cell-size)] items-center gap-1'

export const calendarHeadingButtonClass =
  'h-6 pr-1 pl-1.5 flex min-w-0 flex-1 items-center justify-center gap-1 truncate rounded-(--cell-radius) text-sm font-medium whitespace-nowrap select-none [&>svg]:size-3.5 [&>svg]:shrink-0 [&>svg]:text-muted-foreground'

export const calendarHeadingTextClass = 'text-sm font-medium select-none'

/** Upstream nav button: Button ghost icon at cell size. */
export const calendarNavButtonClass =
  'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button whitespace-nowrap transition-all outline-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground inline-flex items-center justify-center size-(--cell-size) shrink-0 p-0 select-none aria-disabled:opacity-50'

export const calendarGridClass = 'flex w-full min-w-0 flex-col outline-none'

/** Upstream week string with PR spacing fix. */
export const calendarRowClass = 'mt-2 grid w-full grid-cols-7 gap-x-2 gap-y-1'

/** Upstream weekdays string (the weekday *header* row; weeks get mt-2,
 *  weekdays don't). */
export const calendarWeekdaysClass = 'flex'

/** Upstream weekday string. */
export const calendarColumnHeaderClass =
  'flex-1 rounded-(--cell-radius) py-1 text-center text-[0.8rem] font-normal text-muted-foreground select-none'

/** Upstream day-cell string, plus a plain `group` mount: foldkit puts the
 *  state data attrs on this cell, and the day/month/year buttons read them
 *  via group-data-* variants. */
export const calendarCellClass =
  'group group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none'

/** Upstream DayButton string (ghost icon button base) plus foldcn's
 *  group-scoped state hooks. Presence-based because foldkit emits empty
 *  attr values; transition-all replaces what upstream inherits from the
 *  Button cva base, which foldcn's token layer does not carry. */
export const calendarDayButtonClass =
  'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border-transparent bg-clip-padding text-sm focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button shrink-0 whitespace-nowrap outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) items-center justify-center border-0 leading-none font-normal transition-all group-data-[focused]/day:relative group-data-[focused]/day:z-10 group-data-[focused]/day:border-ring group-data-[focused]/day:ring-[3px] group-data-[focused]/day:ring-ring/50 group-data-[selected]:rounded-(--cell-radius) group-data-[selected]:bg-primary group-data-[selected]:text-primary-foreground group-data-[selected]:hover:bg-primary group-data-[selected]:hover:text-primary-foreground group-data-[today]:bg-muted group-data-[today]:text-foreground group-data-[outside-month]:text-muted-foreground group-data-[disabled]:pointer-events-none group-data-[disabled]:text-muted-foreground group-data-[disabled]:opacity-50 dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70'

export const calendarMonthYearGridClass = 'grid w-full grid-cols-3 grid-rows-4 gap-2 outline-none'

export const calendarMonthYearButtonClass =
  'flex h-full w-full cursor-pointer items-center justify-center rounded-(--cell-radius) text-sm tabular-nums transition-colors hover:bg-accent hover:text-accent-foreground group-data-[today]:bg-muted group-data-[today]:text-foreground group-data-[selected]:bg-primary group-data-[selected]:text-primary-foreground group-data-[selected]:hover:bg-primary group-data-[selected]:hover:text-primary-foreground group-data-[focused]:border-ring group-data-[focused]:ring-[3px] group-data-[focused]:ring-ring/50 group-data-[disabled]:pointer-events-none group-data-[disabled]:opacity-40'

const navButton = <M>(
  attributes: ReadonlyArray<ChildAttribute>,
  icon: Html,
  h: HtmlBuilder<M>,
): Html => h.button([...attributes, h.Class(calendarNavButtonClass)], [icon])

const headingButton = <M>(
  heading: DaysModeAttributes['heading'],
  attributes: ReadonlyArray<ChildAttribute>,
  h: HtmlBuilder<M>,
): Html =>
  h.button(
    [h.Id(heading.id), ...attributes, h.Class(calendarHeadingButtonClass)],
    [heading.text, icon(h, ChevronDown, 'size-3')],
  )

const weekRow = <M>(week: Week, showOutsideDays: boolean, h: HtmlBuilder<M>): Html =>
  h.div(
    [...week.attributes, h.Class(calendarRowClass)],
    week.cells.map((cell) =>
      h.div(
        [...cell.cellAttributes, h.Class(calendarCellClass)],
        [
          h.button(
            [
              ...cell.buttonAttributes,
              h.Class(
                showOutsideDays || cell.isInViewMonth
                  ? calendarDayButtonClass
                  : cn(calendarDayButtonClass, 'invisible pointer-events-none'),
              ),
            ],
            [cell.label],
          ),
        ],
      ),
    ),
  )

const daysView = <M>(
  days: DaysModeAttributes,
  h: HtmlBuilder<M>,
  options: CalendarViewOptions | undefined,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'calendar'),
      ...days.root,
      h.Class(cn(calendarContainerClass, options?.containerClass)),
    ],
    [
      h.div(
        [h.Class(calendarHeaderClass)],
        [
          navButton(days.previousMonthButton, icon(h, ChevronLeft, 'rtl:rotate-180 size-4'), h),
          headingButton(days.heading, days.headingButton, h),
          navButton(days.nextMonthButton, icon(h, ChevronRight, 'rtl:rotate-180 size-4'), h),
        ],
      ),
      h.div(
        [...days.grid, h.Class(calendarGridClass)],
        [
          h.div(
            [...days.headerRow, h.Class(calendarWeekdaysClass)],
            days.columnHeaders.map((header) =>
              h.div([...header.attributes, h.Class(calendarColumnHeaderClass)], [header.name]),
            ),
          ),
          h.div(
            [h.Class('flex flex-col min-h-[calc(var(--cell-size)*6+var(--spacing)*5)]')],
            [...days.weeks.map((week) => weekRow(week, options?.showOutsideDays ?? true, h))],
          ),
        ],
      ),
    ],
  )

const monthsView = <M>(
  months: MonthsModeAttributes,
  h: HtmlBuilder<M>,
  options: CalendarViewOptions | undefined,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'calendar'),
      ...months.root,
      h.Class(cn(calendarContainerClass, options?.containerClass)),
    ],
    [
      h.div(
        [h.Class(calendarHeaderClass)],
        [
          h.button(
            [
              h.Id(months.heading.id),
              ...months.headingButton,
              h.Class(cn(calendarHeadingButtonClass, 'col-span-3')),
            ],
            [months.heading.text, icon(h, ChevronDown, 'size-3')],
          ),
        ],
      ),
      h.div(
        [...months.grid, h.Class(calendarMonthYearGridClass)],
        months.cells.map((cell) =>
          h.div(
            [...cell.cellAttributes, h.Class(calendarCellClass)],
            [
              h.button(
                [...cell.buttonAttributes, h.Class(calendarMonthYearButtonClass)],
                [cell.shortLabel],
              ),
            ],
          ),
        ),
      ),
    ],
  )

const yearsView = <M>(
  years: YearsModeAttributes,
  h: HtmlBuilder<M>,
  options: CalendarViewOptions | undefined,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'calendar'),
      ...years.root,
      h.Class(cn(calendarContainerClass, options?.containerClass)),
    ],
    [
      h.div(
        [h.Class(calendarHeaderClass)],
        [
          navButton(years.previousPageButton, icon(h, ChevronLeft, 'rtl:rotate-180 size-4'), h),
          h.h2([h.Id(years.heading.id), h.Class(calendarHeadingTextClass)], [years.heading.text]),
          navButton(years.nextPageButton, icon(h, ChevronRight, 'rtl:rotate-180 size-4'), h),
        ],
      ),
      h.div(
        [...years.grid, h.Class(calendarMonthYearGridClass)],
        years.cells.map((cell) =>
          h.div(
            [...cell.cellAttributes, h.Class(calendarCellClass)],
            [
              h.button(
                [...cell.buttonAttributes, h.Class(calendarMonthYearButtonClass)],
                [cell.label],
              ),
            ],
          ),
        ),
      ),
    ],
  )

export type CalendarViewOptions = Readonly<{
  containerClass?: string
  showOutsideDays?: boolean
}>

export type StyledViewInputs = Readonly<{
  maybeSelectedDate: Option.Option<CalendarDate>
  containerClass?: string
  showOutsideDays?: boolean
}>

/** Styled calendar `toView` callback for the Days/Months/Years modes. Shared
 *  with the date picker's popover panel. */
export const calendarToView = <M>(
  h: HtmlBuilder<M>,
  options?: CalendarViewOptions,
): ((attributes: CalendarAttributes) => Html) =>
  M.type<CalendarAttributes>().pipe(
    M.tagsExhaustive({
      Days: (days) => daysView(days, h, options),
      Months: (months) => monthsView(months, h, options),
      Years: (years) => yearsView(years, h, options),
    }),
  )

/** Build styled `Calendar.ViewInputs`. Pass your view's `h`. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs,
  h: HtmlBuilder<M>,
): ViewInputs => ({
  maybeSelectedDate: viewInputs.maybeSelectedDate,
  toView: calendarToView(h, {
    containerClass: viewInputs.containerClass,
    showOutsideDays: viewInputs.showOutsideDays ?? true,
  }),
})