Event Calendar
import { addDays, addMinutes, type Locale, setHours, startOfDay, startOfWeek } from "date-fns";import { ar, de, es, fr, ja } from "date-fns/locale";import { Plus, SlidersHorizontal } from "lucide-solid";import { createSignal, Show } from "solid-js";import { type CalendarEvent, type CalendarView, EventCalendar, type EventCalendarApi, EventCalendarContent, EventCalendarDatePicker, type EventCalendarI18nOverrides, type EventCalendarInteractions, EventCalendarNav, type EventCalendarOccurrence, type EventCalendarRenderEventProps, type EventCalendarResource, EventCalendarToolbar, type EventCalendarViewSettings,} from "@/registry/kobalte/blocks/event-calendar";import { Avatar, AvatarFallback } from "~/components/ui/avatar";import { Button } from "~/components/ui/button";import { Card, CardContent } from "~/components/ui/card";import { Label } from "~/components/ui/label";import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "~/components/ui/select";import { Switch } from "~/components/ui/switch";import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
/** Team members - passing resources unlocks the resource day view, so the * view switcher offers every view the calendar ships. */const TEAM: EventCalendarResource[] = [ { id: "alex", title: "Alex", color: "var(--color-blue-500)" }, { id: "mia", title: "Mia", color: "var(--color-violet-500)" }, { id: "sam", title: "Sam", color: "var(--color-emerald-500)" },];
const INITIALS: Record<string, string> = { alex: "AL", mia: "MJ", sam: "SP" };
/** Demo events: a balanced current week (timed, multi-day, all-day, two * custom-rendered chips) plus a light scatter in the nearby weeks so the * month view reads naturally without crowding any cell. */function buildEvents(anchor: Date): CalendarEvent[] { const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 }); const at = (dayOffset: number, hour: number, minute = 0) => addMinutes(setHours(addDays(week, dayOffset), hour), minute); const day = (dayOffset: number) => addDays(week, dayOffset);
return [ { id: "team-sync", title: "Team sync", start: at(1, 9, 0), end: at(1, 9, 30), resourceId: "alex", // A weekly series: recurrence accepts a raw RRULE line or the // structured EventCalendarRecurrenceRule shape. recurrence: "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8", }, { id: "design-review", title: "Design review", start: at(2, 11, 0), end: at(2, 12, 0), resourceId: "mia", color: "var(--color-violet-500)", }, { id: "product-demo", title: "Product demo", start: at(3, 15, 0), end: at(3, 16, 0), resourceId: "sam", color: "var(--color-emerald-500)", }, { id: "roadmap-planning", title: "Roadmap planning", start: at(4, 10, 0), end: at(4, 11, 30), resourceId: "alex", color: "var(--color-indigo-500)", }, { id: "client-call", title: "Client call", start: at(5, 14, 0), end: at(5, 15, 0), resourceId: "mia", color: "var(--color-amber-500)", }, { id: "team-offsite", title: "Team offsite", start: day(4), end: day(6), allDay: true, color: "var(--color-rose-500)", }, { id: "sprint-planning", title: "Sprint planning", start: at(9, 9, 30), end: at(9, 10, 30), resourceId: "sam", color: "var(--color-blue-500)", }, { id: "quarterly-review", title: "Quarterly review", start: at(17, 13, 0), end: at(17, 14, 30), resourceId: "alex", color: "var(--color-cyan-500)", }, ];}
/** * Custom chip content for a couple of events - proof that the chip is fully * yours to shape via `renderEvent`. Returning undefined for everything else * falls back to the built-in dot + title + time. */function renderEventContent(props: EventCalendarRenderEventProps) { const event = props.occurrence.event;
// Attendee initials in place of the leading color dot; a thin ring keeps the // overlap crisp at chip size. if (event.id === "design-review") { return ( <> <span class="-space-x-1 flex shrink-0"> <span class="flex size-4 items-center justify-center rounded-full bg-violet-500 font-semibold text-[8px] text-white ring-1 ring-background"> MJ </span> <span class="flex size-4 items-center justify-center rounded-full bg-sky-500 font-semibold text-[8px] text-white ring-1 ring-background"> AL </span> </span> <span class="truncate font-medium">{event.title}</span> </> ); }
// Title with a trailing status pill. The dot, title and pill share one flex // row so the leading dot stays glued to the label - a stacked (flex-col) // timed-grid chip would otherwise drop the dot onto its own line. if (event.id === "client-call") { return ( <span class="flex w-full min-w-0 items-center gap-1.5"> <span aria-hidden class="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)" /> <span class="truncate font-medium">{event.title}</span> <span class="ms-auto shrink-0 rounded bg-(--ec-event-color)/25 px-1 font-semibold text-[10px]"> 30m </span> </span> ); }
return undefined;}
/** Resource columns get an avatar next to the member name. */function renderResourceHeaderContent(props: { resource: EventCalendarResource }) { return ( <span class="flex items-center gap-1.5"> <Avatar size="sm"> <AvatarFallback class="font-medium text-[10px]"> {INITIALS[props.resource.id] ?? props.resource.title.slice(0, 2).toUpperCase()} </AvatarFallback> </Avatar> <span class="truncate">{props.resource.title}</span> </span> );}
/** Styled hover tooltip content (only rendered while `eventTooltip` is on). */function renderEventTooltipContent(props: { occurrence: EventCalendarOccurrence; label: string | undefined;}) { const resource = TEAM.find((member) => member.id === props.occurrence.event.resourceId); return ( <span class="flex flex-col gap-0.5"> <span class="font-medium">{props.label}</span> <Show when={resource}> {(member) => <span class="text-[11px] opacity-80">Owner: {member().title}</span>} </Show> </span> );}
/** * i18n presets - each language ships a date-fns `locale` (localizes every * formatted date: weekday headers, month title, time gutter) plus an `i18n` * override map for the static UI strings the locale can't reach (Today, view * names, "+N more"). Arabic also flips the whole calendar to right-to-left. * English is the built-in default, so it leaves both undefined. */interface DemoLocale { value: string; /** Native language name, shown in the picker. */ label: string; locale: Locale | undefined; dir: "ltr" | "rtl"; i18n: EventCalendarI18nOverrides | undefined;}
const LOCALES: DemoLocale[] = [ { value: "en", label: "English", locale: undefined, dir: "ltr", i18n: undefined }, { value: "de", label: "Deutsch", locale: de, dir: "ltr", i18n: { labels: { today: "Heute", allDay: "Ganztägig", noEvents: "Keine Termine", more: (count) => `+${count} weitere`, }, viewNames: { month: "Monat", week: "Woche", day: "Tag", days: (count) => `${count} Tage`, agenda: "Agenda", resource: "Zeitraster", }, }, }, { value: "fr", label: "Français", locale: fr, dir: "ltr", i18n: { labels: { today: "Aujourd'hui", allDay: "Journée entière", noEvents: "Aucun événement", more: (count) => `+${count} autres`, }, viewNames: { month: "Mois", week: "Semaine", day: "Jour", days: (count) => `${count} jours`, agenda: "Agenda", resource: "Grille horaire", }, }, }, { value: "es", label: "Español", locale: es, dir: "ltr", i18n: { labels: { today: "Hoy", allDay: "Todo el día", noEvents: "Sin eventos", more: (count) => `+${count} más`, }, viewNames: { month: "Mes", week: "Semana", day: "Día", days: (count) => `${count} días`, agenda: "Agenda", resource: "Cuadrícula", }, }, }, { value: "ja", label: "日本語", locale: ja, dir: "ltr", i18n: { labels: { today: "今日", allDay: "終日", noEvents: "予定なし", more: (count) => `他${count}件`, }, viewNames: { month: "月", week: "週", day: "日", days: (count) => `${count}日間`, agenda: "予定", resource: "タイムグリッド", }, }, }, { value: "ar", label: "العربية", locale: ar, dir: "rtl", i18n: { labels: { today: "اليوم", allDay: "طوال اليوم", noEvents: "لا توجد أحداث", more: (count) => `+${count} المزيد`, }, viewNames: { month: "شهر", week: "أسبوع", day: "يوم", days: (count) => `${count} أيام`, agenda: "جدول الأعمال", resource: "شبكة زمنية", }, }, },];
/** Display time zones - all event math and rendering happen in the chosen * zone, so switching it visibly shifts every event's clock time. */const TIME_ZONES: Array<{ value: string; label: string; zone?: string }> = [ { value: "local", label: "Browser" }, { value: "ny", label: "New York", zone: "America/New_York" }, { value: "london", label: "London", zone: "Europe/London" }, { value: "tokyo", label: "Tokyo", zone: "Asia/Tokyo" }, { value: "kolkata", label: "Kolkata", zone: "Asia/Kolkata" },];
/** Everything the settings panel drives, as one resettable object. */interface DemoSettings { viewSettings: EventCalendarViewSettings; interactions: EventCalendarInteractions; weekStartsOn: 0 | 1; dayStartHour: number; dayEndHour: number; interval: number; snapDuration: number; eventTooltip: boolean; showDayAddButton: boolean; localeId: string; timeZoneId: string;}
const DEFAULT_SETTINGS: DemoSettings = { viewSettings: { weekends: true, weekNumbers: false, nowIndicator: true, offDays: false }, interactions: { drag: true, resize: true, selectSlot: true }, weekStartsOn: 0, dayStartHour: 0, dayEndHour: 24, interval: 60, snapDuration: 15, eventTooltip: false, showDayAddButton: false, localeId: "en", timeZoneId: "local",};
type NumberOption = { value: number; label: string };type TextOption = { value: string; label: string };
function SettingsSwitch(props: { id: string; label: string; checked: boolean; onChange: (checked: boolean) => void;}) { return ( <div class="flex items-center justify-between gap-4"> <Label for={props.id} class="font-normal text-sm"> {props.label} </Label> <Switch id={props.id} size="sm" checked={props.checked} onChange={props.onChange} /> </div> );}
function SettingsSelect(props: { label: string; value: number; options: NumberOption[]; onChange: (value: number) => void;}) { const selected = () => props.options.find((option) => option.value === props.value); return ( <div class="flex items-center justify-between gap-4"> <span class="text-sm">{props.label}</span> <Select<NumberOption> options={props.options} optionValue="value" optionTextValue="label" value={selected()} onChange={(option) => option && props.onChange(option.value)} itemComponent={(itemProps) => ( <SelectItem item={itemProps.item}>{itemProps.item.rawValue.label}</SelectItem> )} > <SelectTrigger size="sm" class="w-28" aria-label={props.label}> <SelectValue<NumberOption>>{(state) => state.selectedOption().label}</SelectValue> </SelectTrigger> <SelectContent /> </Select> </div> );}
/** String-keyed sibling of SettingsSelect, for the language/time-zone pickers. */function SettingsTextSelect(props: { label: string; value: string; options: TextOption[]; onChange: (value: string) => void;}) { const selected = () => props.options.find((option) => option.value === props.value); return ( <div class="flex items-center justify-between gap-4"> <span class="text-sm">{props.label}</span> <Select<TextOption> options={props.options} optionValue="value" optionTextValue="label" value={selected()} onChange={(option) => option && props.onChange(option.value)} itemComponent={(itemProps) => ( <SelectItem item={itemProps.item}>{itemProps.item.rawValue.label}</SelectItem> )} > <SelectTrigger size="sm" class="w-36" aria-label={props.label}> <SelectValue<TextOption>>{(state) => state.selectedOption().label}</SelectValue> </SelectTrigger> <SelectContent /> </Select> </div> );}
export default function EventCalendarDemo() { const events = buildEvents(new Date()); // apiRef takes the Solid ref convention: a setter that receives the API. let api: EventCalendarApi | undefined; let newEventCount = 0; const [settings, setSettings] = createSignal<DemoSettings>(DEFAULT_SETTINGS); // Mirror the active view so the settings panel can show the time-grid // internals tab only where those options are visible (week/day/N-days and // the resource time grid - month and agenda render no hour track). const [view, setView] = createSignal<CalendarView>("month"); const [tab, setTab] = createSignal("view"); const isTimeGridView = () => view() !== "month" && view() !== "agenda"; const activeTab = () => (tab() === "time" && !isTimeGridView() ? "view" : tab());
const activeLocale = () => LOCALES.find((entry) => entry.value === settings().localeId) ?? LOCALES[0]; const activeTimeZone = () => TIME_ZONES.find((entry) => entry.value === settings().timeZoneId) ?? TIME_ZONES[0];
const patch = (partial: Partial<DemoSettings>) => setSettings((current) => ({ ...current, ...partial })); const patchViewSettings = (partial: EventCalendarViewSettings) => patch({ viewSettings: { ...settings().viewSettings, ...partial } }); const patchInteractions = (partial: Partial<EventCalendarInteractions>) => patch({ interactions: { ...settings().interactions, ...partial } });
// Add a one-hour event at noon today and jump to it - a minimal stand-in for // a real "create event" dialog. const addEvent = () => { if (!api) return; const start = setHours(startOfDay(new Date()), 12); const end = addMinutes(start, 60); api.addEvent({ id: `new-event-${newEventCount++}`, title: "New event", start, end, resourceId: "alex", color: "var(--color-blue-500)", }); api.goTo(start); };
return ( <div class="w-full p-4" dir={activeLocale().dir}> <Card class="w-full py-0"> <CardContent class="p-0"> <EventCalendar defaultEvents={events} defaultView="month" onViewChange={setView} resources={TEAM} apiRef={(instanceApi) => { api = instanceApi; }} renderEvent={renderEventContent} renderResourceHeader={renderResourceHeaderContent} renderEventTooltip={renderEventTooltipContent} locale={activeLocale().locale} i18n={activeLocale().i18n} timeZone={activeTimeZone().zone} viewSettings={settings().viewSettings} onViewSettingsChange={(viewSettings) => patch({ viewSettings })} interactions={settings().interactions} onInteractionsChange={(interactions) => patch({ interactions })} weekStartsOn={settings().weekStartsOn} dayStartHour={settings().dayStartHour} dayEndHour={settings().dayEndHour} interval={settings().interval} snapDuration={settings().snapDuration} eventTooltip={settings().eventTooltip} showDayAddButton={settings().showDayAddButton} // Object form of offDays: the marker class hook is named `class` // in this port, unlike the React original. offDays={{ weekendDays: [0, 6], class: "bg-muted/40" }} class="h-[640px] w-full" > <div class="flex flex-wrap items-center gap-2 pe-2"> <EventCalendarNav class="min-w-0 flex-1" /> <EventCalendarToolbar> {/* Not part of the default nav - compose it wherever it fits. */} <EventCalendarDatePicker /> <Popover placement="bottom-end" gutter={8}> <PopoverTrigger as={Button} variant="outline" size="sm"> <SlidersHorizontal class="size-4" aria-hidden="true" /> Settings </PopoverTrigger> <PopoverContent class="w-80"> <Tabs value={activeTab()} onChange={setTab}> <TabsList class="w-full"> <TabsTrigger value="view" class="flex-1"> View </TabsTrigger> {/* time-grid internals only exist where an hour track renders, so the tab follows the active view */} <Show when={isTimeGridView()}> <TabsTrigger value="time" class="flex-1"> Time grid </TabsTrigger> </Show> <TabsTrigger value="behavior" class="flex-1"> Behavior </TabsTrigger> <TabsTrigger value="region" class="flex-1"> Region </TabsTrigger> </TabsList> <TabsContent value="view" class="flex flex-col gap-3 pt-3"> <SettingsSwitch id="ec-set-weekends" label="Weekends" checked={settings().viewSettings.weekends ?? true} onChange={(weekends) => patchViewSettings({ weekends })} /> <SettingsSwitch id="ec-set-week-numbers" label="Week numbers" checked={settings().viewSettings.weekNumbers ?? false} onChange={(weekNumbers) => patchViewSettings({ weekNumbers })} /> <SettingsSwitch id="ec-set-now" label="Now indicator" checked={settings().viewSettings.nowIndicator ?? true} onChange={(nowIndicator) => patchViewSettings({ nowIndicator })} /> <SettingsSwitch id="ec-set-off-days" label="Mark off days" checked={settings().viewSettings.offDays ?? false} onChange={(offDays) => patchViewSettings({ offDays })} /> <SettingsSwitch id="ec-set-day-add" label="Day add button" checked={settings().showDayAddButton} onChange={(showDayAddButton) => patch({ showDayAddButton })} /> {/* week start shapes month and week grids alike, so it lives here rather than in time-grid internals */} <SettingsSelect label="Week starts" value={settings().weekStartsOn} options={[ { value: 0, label: "Sunday" }, { value: 1, label: "Monday" }, ]} onChange={(weekStartsOn) => patch({ weekStartsOn: weekStartsOn === 1 ? 1 : 0 }) } /> </TabsContent> <TabsContent value="time" class="flex flex-col gap-3 pt-3"> <SettingsSelect label="Day starts" value={settings().dayStartHour} options={[ { value: 0, label: "00:00" }, { value: 6, label: "06:00" }, { value: 8, label: "08:00" }, ]} onChange={(dayStartHour) => patch({ dayStartHour })} /> <SettingsSelect label="Day ends" value={settings().dayEndHour} options={[ { value: 18, label: "18:00" }, { value: 20, label: "20:00" }, { value: 24, label: "24:00" }, ]} onChange={(dayEndHour) => patch({ dayEndHour })} /> <SettingsSelect label="Grid interval" value={settings().interval} options={[ { value: 30, label: "30 min" }, { value: 60, label: "60 min" }, ]} onChange={(interval) => patch({ interval })} /> <SettingsSelect label="Drag snap" value={settings().snapDuration} options={[ { value: 5, label: "5 min" }, { value: 15, label: "15 min" }, { value: 30, label: "30 min" }, ]} onChange={(snapDuration) => patch({ snapDuration })} /> </TabsContent> <TabsContent value="behavior" class="flex flex-col gap-3 pt-3"> <SettingsSwitch id="ec-set-drag" label="Drag to move" checked={settings().interactions.drag} onChange={(drag) => patchInteractions({ drag })} /> <SettingsSwitch id="ec-set-resize" label="Drag to resize" checked={settings().interactions.resize} onChange={(resize) => patchInteractions({ resize })} /> <SettingsSwitch id="ec-set-select-slot" label="Drag to create" checked={settings().interactions.selectSlot} onChange={(selectSlot) => patchInteractions({ selectSlot })} /> <SettingsSwitch id="ec-set-tooltip" label="Event tooltips" checked={settings().eventTooltip} onChange={(eventTooltip) => patch({ eventTooltip })} /> </TabsContent> <TabsContent value="region" class="flex flex-col gap-3 pt-3"> <SettingsTextSelect label="Language" value={settings().localeId} options={LOCALES.map((entry) => ({ value: entry.value, label: entry.label, }))} onChange={(localeId) => patch({ localeId })} /> <SettingsTextSelect label="Time zone" value={settings().timeZoneId} options={TIME_ZONES.map((entry) => ({ value: entry.value, label: entry.label, }))} onChange={(timeZoneId) => patch({ timeZoneId })} /> <p class="text-muted-foreground text-xs leading-relaxed"> Language switches the date-fns locale and every UI label. Time zone shifts all event times. Arabic also flips the calendar to right-to-left. </p> </TabsContent> </Tabs> <Button variant="outline" size="sm" class="mt-4 w-full" onClick={() => setSettings(DEFAULT_SETTINGS)} > Reset to defaults </Button> </PopoverContent> </Popover> <Button size="sm" onClick={addEvent}> <Plus class="size-4" aria-hidden="true" /> New event </Button> </EventCalendarToolbar> </div> <EventCalendarContent /> </EventCalendar> </CardContent> </Card> </div> );}The demo above is the whole feature surface in one example: every view (month, week, day, N-days, agenda, and the resource time grid), a multi-day all-day bar, a recurring series, custom chips via renderEvent, a composed date picker, and a tabbed Settings panel driving view settings, time-grid options, and interactions as controlled props — with a reset back to defaults.
EventCalendar separates the calendar engine from the calendar UI. A Solid store (useEventCalendarState) owns events, view, date, selection, and interactions with controlled and uncontrolled modes for every state pair; the shipped view components (month, week, day, N-days, agenda, and a resource day grid) render from that store through fine-grained accessors, so a write only invalidates the cells that actually changed. Events are yours: the calendar never persists anything, it proposes changes through onEventUpdate and you accept, adjust, or reject them.
The composition contract is a provider plus slots: EventCalendar wraps EventCalendarNav, an optional EventCalendarToolbar, and EventCalendarContent. Recurrence (an RFC 5545 subset, structured rules or raw RRULE strings), display time zones via @date-fns/tz, pointer-based drag, resize, and drag-create, and per-key i18n overrides are built in.
Installation
Usage
import { createSignal } from "solid-js";import { type CalendarEvent, EventCalendar, EventCalendarContent, EventCalendarNav,} from "~/components/blocks/event-calendar";const [events, setEvents] = createSignal<CalendarEvent[]>(initialEvents);
return ( <EventCalendar events={events()} onEventsChange={setEvents} defaultView="week" class="h-[600px]" > <EventCalendarNav /> <EventCalendarContent /> </EventCalendar>);Pass defaultEvents for uncontrolled state or events + onEventsChange for controlled state; the same pairing exists for view, date, dayCount, selection, interactions, and viewSettings. In the default scrollMode="contained" the calendar fills its container and scrolls internally, so give the root an explicit height. Every timing change, whether from a drag, a resize, or api.updateEvent, funnels through onEventUpdate: return false to reject and revert, return nothing (or true) to accept, or return { start, end, allDay } to accept with an adjustment, then persist from onEventsChange or inside onEventUpdate itself. Use onRangeChange to fetch remote events for the visible range, and apiRef (or a hoisted useEventCalendarState instance passed as calendar) for imperative control from outside the tree.
Solid API notes
This is a port of the React original, so a few shapes differ. Everything else — component names, prop names, data-slot attributes, classes, and behavior — is unchanged.
- No
renderprop. Every component renders a plain element and forwardsComponentProps, so style it withclassand compose with children instead. class, notclassName— includingEventCalendarOffDaysConfig.class. The class maps keep their upstream names:classNames,dayClassName,todayClassName.- The instance has no
subscribe.EventCalendarInstanceis{ getState, api, settings, internals }; fine-grained reads replace the snapshot/listener machinery.getState()returns a live object of getters — read a field inside a memo or JSX and you subscribe to exactly that field. - Selector-style hooks return accessors.
useEventCalendarSelector,useEventCalendarOccurrences, anduseNowreturnAccessor<T>; call them. The "bag" hooks (useEventCalendarView,useEventCalendarNavigation,useEventCalendarSelection,useEventCalendarInteractions,useEventCalendarDay,useEventCalendarWeek,useEventCalendarViewSettings) return objects of getters, so property access reads exactly like the React version. - Day/range arguments accept accessors.
useEventCalendarDay,useEventCalendarWeek, anduseEventCalendarOccurrencestakeDate | (() => Date)(respectively a range or an accessor of one), so they stay reactive without an effect. apiReffollows the Solid ref convention. Pass a setter(api) => void, or a{ current }box if you prefer the React shape. Both are the exportedEventCalendarApiReftype.JSX.ElementreplacesReactNode,MouseEventreplacesReact.MouseEvent,JSX.CSSPropertiesreplacesCSSProperties, and gesture callbacks take a nativePointerEvent(there is noe.nativeEvent).onDblClickis the DOM double-click prop onEventCalendarEvent(Solid's name). The calendar-level callback is stillonEventDoubleClick.styleis object-only onEventCalendarEvent,EventCalendarTimeGrid, andEventCalendarResourceView: those three merge their own CSS variable into it and Solid has no style-merging spread.EventCalendarPropsomits the DOMonSelectionChangehandler, because the calendar callback of the same name wins.- The chip's
data-slot="event-calendar-event"is calendar-owned and re-asserted last, so it cannot be overridden by a consumer prop; the drag carry clone and several view queries depend on it. useEventCalendarSettingsVersionis vestigial. It still returns a number, but fine-grained reactivity makes it unnecessary for rendering.
API Reference
EventCalendar
The root provider and container. It creates (or adopts) the calendar instance, provides it via context, and renders a flex-column div. Besides the props below, it accepts every option and callback listed under State options and Callbacks, every display prop listed under View configuration, and every other div prop.
The root renders data-slot="event-calendar" plus an aria-live="polite" announcer element.
EventCalendarNav
Default composed navigation: Today, view switcher, prev/next, title, and a trailing spacer. Pass children to use it as a pure layout shell and compose the nav parts yourself. Renders as a div.
EventCalendarNavToday
The "Today" navigation button; resets the calendar to now. It follows the configured navButtonVariant / navButtonSize and the classNames.navButton hook, and gets data-active while the anchor period contains now. EventCalendarNavPrev and EventCalendarNavNext share the same props.
EventCalendarNavPrev
The previous-period navigation button; steps the anchor date one period back for the current view. Same props as EventCalendarNavToday, except tooltip defaults to the i18n "previous" label and children replaces the default chevron icon.
EventCalendarNavNext
The next-period navigation button; steps the anchor date one period forward for the current view. Same props as EventCalendarNavToday, except tooltip defaults to the i18n "next" label and children replaces the default chevron icon.
EventCalendarTitle
The current period title (from i18n.functions.formatTitle), marked aria-live="polite".
EventCalendarViewSwitcher
Dropdown listing the available views (the resolved views option) with optional keyboard-shortcut hints; when the days view is enabled, one item per dayCountPresets entry is offered. Built on the Kobalte Dropdown Menu, so items activate through onSelect and the menu is keyboard-operable.
EventCalendarDatePicker
Optional go-to-date popover (the Zaidan Calendar), not part of the default nav; compose it yourself. It is view-aware: week, N-days, and agenda highlight the whole active range, other views select a single date.
EventCalendarToolbar
Free slot for consumer toolbar buttons; a pure layout shell (flex items-center gap-2) with the classNames.toolbar hook. No props beyond standard div props.
EventCalendarContent
Active-view switchboard: renders the component registered for the current view and exposes data-view / data-loading attributes. While loading is true it dims and disables pointer events.
EventCalendarMonthView
ARIA-grid month view: week rows, day cells, continuous multi-day bars in a lane overlay, "+N more" overflow, week numbers, and the day add affordance.
EventCalendarTimeGrid
The shared week/day/N-days engine: hour gutter, minute-positioned event blocks with overlap packing, the all-day row, drag ghosts, and the now indicator. You usually render one of the wrappers below instead.
EventCalendarWeekView
Thin wrapper around EventCalendarTimeGrid with view preset to "week". Accepts every EventCalendarTimeGrid prop except view.
EventCalendarDayView
Thin wrapper around EventCalendarTimeGrid with view preset to "day". Accepts every EventCalendarTimeGrid prop except view.
EventCalendarDaysView
Thin wrapper around EventCalendarTimeGrid with view preset to "days". Accepts every EventCalendarTimeGrid prop except view.
EventCalendarAgendaView
Chronological list of the upcoming window, grouped by day with a date gutter per group, one row per occurrence, and a compact empty state. The window length comes from the agendaDayCount option, so the view has no props beyond standard div props.
EventCalendarResourceView
Resource-columns day grid for booking scenarios: one time axis, one column per leaf resource (from the resources option, flattened depth-first), with full drag, resize, and drag-create across columns.
EventCalendarEvent
The one interactive chip used by every view. The wrapper owns positioning, a11y, selection, drag/resize listeners, and data attributes; the content comes from children, the root renderEvent / renderAgendaEvent override, or the built-in default. Accepts every button prop except children and style.
EventCalendarApi
The imperative API, available as instance.api, through apiRef, or from any hook's instance. Reading methods reflect the current state; writing methods respect controlled props (they call the matching on*Change callback and only mutate internal state for uncontrolled fields).
CalendarEvent
Your event objects. TData is a fully generic consumer payload.
EventCalendarResource
A bookable resource (room, person, equipment). Nested resources render their leaves as booking columns in the resource view.
EventCalendarRecurrenceRule
The structured RFC 5545 subset. A raw RRULE string with the same parts is equally accepted on event.recurrence.
EventCalendarWeekday is "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU". For rule parts outside this subset, plug a full RRULE engine through the getOccurrences option; unsupported parts throw EventCalendarRecurrenceError.
EventCalendarOccurrence
One expanded instance of an event, as passed to click callbacks and returned by api.getOccurrences.
EventCalendarSegment
A per-day slice of an occurrence, produced by the event index and consumed by EventCalendarEvent and the layout hooks. Positional fields are filled per surface: startMin / endMin for timed blocks, lane / column / columnCount / columnSpan for packing, and rowIndex / colStart / colSpan for month/all-day bars. isStart / isEnd and continuesBefore / continuesAfter mark cross-day edges.
EventCalendarProposedUpdate
The payload of onEventUpdate and canDropEvent: a proposed timing (and optionally resource) change.
The onEventUpdate return type is EventCalendarUpdateResult: false rejects and reverts, void or true accepts, and { start?, end?, allDay? } accepts with an adjustment.
EventCalendarSlotInfo
The payload of onSlotClick. A click is a point, not a range.
EventCalendarSlotDraft
The drag-create rectangle, passed to onSelectSlot and canSelectSlot. The in-progress draft lives in state as slotDraft and is cleared on commit or cancel; the committed slot selection lives in selection.slot.
EventCalendarState
The live state object, as received by useEventCalendarSelector selectors and returned by instance.getState(). Every field is a getter, so reading one subscribes to exactly that field.
EventCalendarDateRange is { start: Date; end: Date } with an inclusive start and an exclusive end. The exported EventCalendarDataAdapter type (getEvents(range, signal?)) describes the shape of an external data source; wiring it to a backend is application territory.
Hooks
Most hooks require an EventCalendar ancestor. The exceptions: useEventCalendarState creates the instance itself, useNow is standalone, useEventCalendarSettingsVersion takes the instance as an argument, and hooks that accept an explicit calendar option work outside the tree.
An EventCalendarInstance is { getState, api, settings, internals }; internals is cross-file plumbing for sibling view modules and not public API. EventCalendarContext, EventCalendarViewConfigContext, and EventCalendarViewContext are exported for advanced composition.
Helpers
Pure, framework-free helpers exported from event-calendar-lib.tsx (plus the gesture flags from event-calendar-dnd.tsx). event-calendar-lib.tsx also exports the advanced types EventCalendarIndex, EventCalendarDayBucket, and EventCalendarWeekRow used in these signatures.
Useful exported constants: BASE_VIEWS and ALL_VIEWS (the view lists), DEFAULT_VIEW_CONFIG, DEFAULT_VIEW_COMPONENTS (the view switchboard map), EVENT_CALENDAR_ACTIVATION (gesture defaults), EVENT_CALENDAR_COLORS (ten Tailwind palette presets for event.color), EVENT_CALENDAR_GHOST (the standardized drag-ghost classes), EVENT_CALENDAR_FADE_TRUNCATE (the fade-out truncation classes, reusable in renderEvent content), MIN_PACK_SLOT (packing-effective minimum of 30 minutes), and MAX_OCCURRENCES (recurrence expansion cap of 1000 per event).
Recurrence helpers
Exported from event-calendar-recurrence.tsx. The supported RRULE subset is FREQ (daily, weekly, monthly, yearly), INTERVAL, COUNT, UNTIL, BYDAY (with ordinals for monthly/yearly), BYMONTHDAY, BYMONTH, and WKST; anything else throws EventCalendarRecurrenceError, whose message points at the getOccurrences escape hatch.
Config
State options
Option props accepted by EventCalendar and useEventCalendarState. Controlled/uncontrolled pairs follow the standard convention: pass the controlled prop plus its on*Change callback, or the default* prop for internal state.
EventCalendarActivationConfig defaults: moveDistancePx: 5, createDistancePx: 4, touchDelayMs: 250, touchTolerancePx: 5, autoScrollEdgePx: 48, autoScrollMaxStepPx: 15 (exported as EVENT_CALENDAR_ACTIVATION).
Callbacks
Callback props accepted by EventCalendar and useEventCalendarState, alongside the options above. Event arguments are native DOM events.
View configuration
Display props and render overrides, accepted directly on EventCalendar (they live in the view layer, never in the headless options). Defaults come from DEFAULT_VIEW_CONFIG. Passing undefined keeps the default rather than erasing it.
Render overrides, all optional and all part of the same view config. Each returns a JSX.Element.
EventCalendarViewConfig also declares renderAgendaEventDetails, renderAgendaDayHeader, and renderAgendaDaySummary, plus the agendaDay, agendaDayGutter, agendaDate, agendaDayToggle, agendaDayContent, agendaItemSurface, agendaItemToggle, agendaDaySummary, and agendaSummaryDot class keys. They are part of the type surface, but the shipped agenda view does not render collapsible days or expandable details, so nothing reads them today.
The classNames object (EventCalendarClassNames) offers one string hook per element, cn-merged after the built-in classes so Tailwind variants and ! overrides win. Available keys: nav, toolbar, content, monthView, monthCell, timeGrid, timeGutter, dayColumn, allDaySection, agendaView, event, eventTooltip, moreIndicator, morePopover, morePopoverHeader; nav family navButton, title, navTooltip, viewSwitcherContent, viewSwitcherLabel, viewShortcut, datePickerContent; month view monthHeader, monthDayHeader, monthBody, monthRow, weekNumber, monthBarOverlay, monthBar, monthCellContent, monthCellFooter, monthDayNumber, dayAddButton; time grid and resource timeGridHeader, timeGutterLabel, allDayLabel, allDayCell, timedChip, resourceHeader; interaction surfaces dragGhost, dragCarry, dragCarryInvalid, dropHint, dropIndicator, slotDraft, resizeHandle, resizeGrip; agenda noEvents, agendaDayHeader, agendaItem (plus the unused agenda keys listed above). Metric CSS variables can ride on any parent key, for example classNames.timeGrid: "[--ec-gutter-width:4rem]" or classNames.morePopover: "[--ec-more-max-height:20rem]".
EventCalendarInteractions
Interaction toggles, controllable via the interactions prop or api.setInteractions. All three default to true.
EventCalendarViewSettings
User-adjustable display toggles (the "view settings" state), controllable via viewSettings / onViewSettingsChange or api.setViewSettings. Every field is optional; undefined defers to the matching view-config prop (see useEventCalendarViewSettings().effective).
EventCalendarOffDaysConfig
Configuration for non-working-day marking, passed as the offDays view config. true uses the defaults (weekends with a muted background); marked cells carry data-off for CSS-selector customization.
Internationalization
The i18n option accepts an EventCalendarI18nOverrides object with four sections, shallow-merged per nested object so a partial override replaces individual keys, never whole sections:
labels: UI strings and label functions (today,previous,next,addEvent,allDay,more(count),noEvents,loading,event,events(count),selectView,week(weekNumber),resources,goToDate,dropNotAllowed,continues,timeFrom(time),timeUntil(time),viewShortcuts,toggleDayEvents(count, expanded),eventDetails(title),moreCompact(count),timeRange(from, to)).viewNames: display names per view (month,week,day,days(count),agenda,resource).formats: date-fns format strings applied with the calendarlocale(monthTitle,weekTitle,dayTitle,agendaTitle,monthDayHeader,monthDayHeaderNarrow,timeGridDayHeader,agendaDayHeader,agendaDayNumber,agendaWeekday,moreDayHeader,monthCellAriaLabel,dayAria,resourceTitle,timeGutter,timeGutterMinute,eventTime,monthCellDay). LeavingweekTitle/agendaTitleundefined keeps the smart cross-month range title.functions: formatter functions (formatTitle,formatEventTime,formatDayRange,formatEventLabel,formatEventAriaLabel). The defaults are re-bound to the merged labels and formats, so overridingformats.monthTitleflows into the defaultformatTitlewithout replacing it.
mergeEventCalendarI18n(overrides?) performs this merge and DEFAULT_EVENT_CALENDAR_I18N holds the defaults; both are exported from event-calendar-i18n.tsx for standalone use.
Accessibility
- The month view is an ARIA grid: week rows are
row, day cells aregridcell, and the header cells arecolumnheader; the week-number gutter usesrowheader. - Event chips are real buttons with an aria label built from
i18n.functions.formatEventAriaLabel, so the whole grid is tab and arrow navigable, and the month view restores chip focus after a drag recommits the lanes. - The root renders an
aria-live="polite"announcer (data-slot="event-calendar-announcer") that gesture code writes into, andEventCalendarTitleis itselfaria-live="polite"so the period is announced on navigation. - View-switcher shortcuts are on by default and scoped to
focus-within, so they never hijack keys typed elsewhere on the page; setshortcutsScope="global"orenableShortcuts={false}to change that. - Nav tooltips open on hover and keyboard focus-visible only, and are force-closed while the overlay they belong to is open.