Custom Shadcn Event Calendar for React and Tailwind CSS. A headless-first event calendar with month, week, day, N-day and agenda views, drag-and-drop scheduling, recurring events, time zones, and an external CRUD contract.
"use client"import { useMemo, useRef, useState } from "react"import { EventCalendar, type EventCalendarApi, type EventCalendarRenderEventProps,
} from "@/components/reui/event-calendar/event-calendar"
import { EventCalendarContent } from "@/components/reui/event-calendar/event-calendar-content"
import type { EventCalendarI18nOverrides } from "@/components/reui/event-calendar/event-calendar-i18n"
import {
EventCalendarNav,
EventCalendarToolbar,
} from "@/components/reui/event-calendar/event-calendar-nav"
import type {
CalendarEvent,
CalendarView,
EventCalendarInteractions,
EventCalendarResource,
EventCalendarViewSettings,
} from "@/components/reui/event-calendar/event-calendar-types"
import {
addDays,
addMinutes,
setHours,
startOfDay,
startOfWeek,
type Locale,
} from "date-fns"
import { ar, de, es, fr, ja } from "date-fns/locale"
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"
import { PlusIcon, Settings2Icon } from 'lucide-react'
/** 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)" },
]
/** 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",
},
{
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({ occurrence }: EventCalendarRenderEventProps) {
const { event } = occurrence
// Attendee avatars (real shadcn Avatar + AvatarFallback) in place of the
// leading color dot; a thin ring-1 keeps the overlap crisp at this size.
if (event.id === "design-review") {
return (
<>
<span className="flex shrink-0 -space-x-1">
<Avatar className="ring-background size-4 ring-1">
<AvatarFallback className="bg-violet-500 text-[8px] font-semibold text-white">
MJ
</AvatarFallback>
</Avatar>
<Avatar className="ring-background size-4 ring-1">
<AvatarFallback className="bg-sky-500 text-[8px] font-semibold text-white">
AL
</AvatarFallback>
</Avatar>
</span>
<span className="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 className="flex w-full min-w-0 items-center gap-1.5">
<span
aria-hidden
className="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)"
/>
<span className="truncate font-medium">{event.title}</span>
<span className="ms-auto shrink-0 rounded bg-(--ec-event-color)/25 px-1 text-[10px] font-semibold">
30m
</span>
</span>
)
}
return undefined
}
/**
* 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 {
id: string
/** Native language name, shown in the picker. */
label: string
locale: Locale | undefined
dir: "ltr" | "rtl"
i18n: EventCalendarI18nOverrides | undefined
}
const LOCALES: DemoLocale[] = [
{
id: "en",
label: "English",
locale: undefined,
dir: "ltr",
i18n: undefined,
},
{
id: "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",
},
},
},
{
id: "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",
},
},
},
{
id: "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",
},
},
},
{
id: "ja",
label: "日本語",
locale: ja,
dir: "ltr",
i18n: {
labels: {
today: "今日",
allDay: "終日",
noEvents: "予定なし",
more: (count) => `他${count}件`,
},
viewNames: {
month: "月",
week: "週",
day: "日",
days: (count) => `${count}日間`,
agenda: "予定",
resource: "タイムグリッド",
},
},
},
{
id: "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<{ id: string; label: string; value?: string }> = [
{ id: "local", label: "Browser" },
{ id: "ny", label: "New York", value: "America/New_York" },
{ id: "london", label: "London", value: "Europe/London" },
{ id: "tokyo", label: "Tokyo", value: "Asia/Tokyo" },
{ id: "kolkata", label: "Kolkata", value: "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",
}
function SettingsSwitch({
id,
label,
checked,
onCheckedChange,
}: {
id: string
label: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-4">
<Label htmlFor={id} className="font-normal">
{label}
</Label>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
</div>
)
}
function SettingsSelect({
id,
label,
value,
options,
onValueChange,
}: {
id: string
label: string
value: number
options: Array<{ value: number; label: string }>
onValueChange: (value: number) => void
}) {
return (
<div className="flex items-center justify-between gap-4">
<Label htmlFor={id} className="font-normal">
{label}
</Label>
<Select
value={String(value)}
onValueChange={(next) => onValueChange(Number(next))}
>
<SelectTrigger id={id} size="sm" className="w-28">
{/* Base UI's Value renders the raw value string by default; the
selected option's label reads better for every control here. */}
<SelectValue>
{options.find((option) => option.value === value)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
/** String-keyed sibling of SettingsSelect, for the language/time-zone pickers. */
function SettingsTextSelect({
id,
label,
value,
options,
onValueChange,
}: {
id: string
label: string
value: string
options: Array<{ value: string; label: string }>
onValueChange: (value: string) => void
}) {
return (
<div className="flex items-center justify-between gap-4">
<Label htmlFor={id} className="font-normal">
{label}
</Label>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger id={id} size="sm" className="w-36">
{/* Base UI's Value renders the raw value string by default; the
selected option's label reads better for every control here. */}
<SelectValue>
{options.find((option) => option.value === value)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
export function Pattern() {
const events = useMemo(() => buildEvents(new Date()), [])
const apiRef = useRef<EventCalendarApi | null>(null)
const newEventCount = useRef(0)
const [settings, setSettings] = useState<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] = useState<CalendarView>("month")
const isTimeGridView = view !== "month" && view !== "agenda"
const activeLocale =
LOCALES.find((entry) => entry.id === settings.localeId) ?? LOCALES[0]
const activeTimeZone =
TIME_ZONES.find((entry) => entry.id === settings.timeZoneId) ??
TIME_ZONES[0]
const patch = (partial: Partial<DemoSettings>) =>
setSettings((current) => ({ ...current, ...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 = () => {
const api = apiRef.current
if (!api) return
const start = setHours(startOfDay(new Date()), 12)
const end = addMinutes(start, 60)
api.addEvent({
id: `new-event-${newEventCount.current++}`,
title: "New event",
start,
end,
resourceId: "alex",
color: "var(--color-blue-500)",
})
api.goTo(start)
}
return (
<div className="w-full p-4" dir={activeLocale.dir}>
<Card className="w-full py-0">
<CardContent className="p-0">
<EventCalendar
defaultEvents={events}
defaultView="month"
onViewChange={setView}
resources={TEAM}
apiRef={apiRef}
renderEvent={renderEventContent}
locale={activeLocale.locale}
i18n={activeLocale.i18n}
timeZone={activeTimeZone.value}
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}
offDays
className="h-[640px] w-full"
>
<div className="flex flex-wrap items-center gap-2 pe-2">
<EventCalendarNav className="min-w-0 flex-1" />
<EventCalendarToolbar>
<Popover>
<PopoverTrigger
render={<Button variant="outline" size="sm" />}
>
<Settings2Icon className="size-4" aria-hidden="true" />
Settings
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80">
<Tabs defaultValue="view">
<TabsList className="w-full">
<TabsTrigger value="view" className="flex-1">
View
</TabsTrigger>
{/* time-grid internals only exist where an hour track
renders, so the tab follows the active view */}
{isTimeGridView && (
<TabsTrigger value="time" className="flex-1">
Time grid
</TabsTrigger>
)}
<TabsTrigger value="behavior" className="flex-1">
Behavior
</TabsTrigger>
<TabsTrigger value="region" className="flex-1">
Region
</TabsTrigger>
</TabsList>
<TabsContent value="view" className="flex flex-col gap-3">
<SettingsSwitch
id="ec-set-weekends"
label="Weekends"
checked={settings.viewSettings.weekends ?? true}
onCheckedChange={(weekends) =>
patch({
viewSettings: {
...settings.viewSettings,
weekends,
},
})
}
/>
<SettingsSwitch
id="ec-set-week-numbers"
label="Week numbers"
checked={settings.viewSettings.weekNumbers ?? false}
onCheckedChange={(weekNumbers) =>
patch({
viewSettings: {
...settings.viewSettings,
weekNumbers,
},
})
}
/>
<SettingsSwitch
id="ec-set-now"
label="Now indicator"
checked={settings.viewSettings.nowIndicator ?? true}
onCheckedChange={(nowIndicator) =>
patch({
viewSettings: {
...settings.viewSettings,
nowIndicator,
},
})
}
/>
<SettingsSwitch
id="ec-set-off-days"
label="Mark off days"
checked={settings.viewSettings.offDays ?? false}
onCheckedChange={(offDays) =>
patch({
viewSettings: {
...settings.viewSettings,
offDays,
},
})
}
/>
<SettingsSwitch
id="ec-set-day-add"
label="Day add button"
checked={settings.showDayAddButton}
onCheckedChange={(showDayAddButton) =>
patch({ showDayAddButton })
}
/>
{/* week start shapes month and week grids alike, so
it lives here rather than in time-grid internals */}
<SettingsSelect
id="ec-set-week-start"
label="Week starts"
value={settings.weekStartsOn}
options={[
{ value: 0, label: "Sunday" },
{ value: 1, label: "Monday" },
]}
onValueChange={(weekStartsOn) =>
patch({ weekStartsOn: weekStartsOn as 0 | 1 })
}
/>
</TabsContent>
<TabsContent value="time" className="flex flex-col gap-3">
<SettingsSelect
id="ec-set-day-start"
label="Day starts"
value={settings.dayStartHour}
options={[
{ value: 0, label: "00:00" },
{ value: 6, label: "06:00" },
{ value: 8, label: "08:00" },
]}
onValueChange={(dayStartHour) =>
patch({ dayStartHour })
}
/>
<SettingsSelect
id="ec-set-day-end"
label="Day ends"
value={settings.dayEndHour}
options={[
{ value: 18, label: "18:00" },
{ value: 20, label: "20:00" },
{ value: 24, label: "24:00" },
]}
onValueChange={(dayEndHour) => patch({ dayEndHour })}
/>
<SettingsSelect
id="ec-set-interval"
label="Grid interval"
value={settings.interval}
options={[
{ value: 30, label: "30 min" },
{ value: 60, label: "60 min" },
]}
onValueChange={(interval) => patch({ interval })}
/>
<SettingsSelect
id="ec-set-snap"
label="Drag snap"
value={settings.snapDuration}
options={[
{ value: 5, label: "5 min" },
{ value: 15, label: "15 min" },
{ value: 30, label: "30 min" },
]}
onValueChange={(snapDuration) =>
patch({ snapDuration })
}
/>
</TabsContent>
<TabsContent
value="behavior"
className="flex flex-col gap-3"
>
<SettingsSwitch
id="ec-set-drag"
label="Drag to move"
checked={settings.interactions.drag}
onCheckedChange={(drag) =>
patch({
interactions: { ...settings.interactions, drag },
})
}
/>
<SettingsSwitch
id="ec-set-resize"
label="Drag to resize"
checked={settings.interactions.resize}
onCheckedChange={(resize) =>
patch({
interactions: {
...settings.interactions,
resize,
},
})
}
/>
<SettingsSwitch
id="ec-set-select-slot"
label="Drag to create"
checked={settings.interactions.selectSlot}
onCheckedChange={(selectSlot) =>
patch({
interactions: {
...settings.interactions,
selectSlot,
},
})
}
/>
<SettingsSwitch
id="ec-set-tooltip"
label="Event tooltips"
checked={settings.eventTooltip}
onCheckedChange={(eventTooltip) =>
patch({ eventTooltip })
}
/>
</TabsContent>
<TabsContent
value="region"
className="flex flex-col gap-3"
>
<SettingsTextSelect
id="ec-set-language"
label="Language"
value={settings.localeId}
options={LOCALES.map((entry) => ({
value: entry.id,
label: entry.label,
}))}
onValueChange={(localeId) => patch({ localeId })}
/>
<SettingsTextSelect
id="ec-set-timezone"
label="Time zone"
value={settings.timeZoneId}
options={TIME_ZONES.map((entry) => ({
value: entry.id,
label: entry.label,
}))}
onValueChange={(timeZoneId) => patch({ timeZoneId })}
/>
<p className="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"
className="mt-4 w-full"
onClick={() => setSettings(DEFAULT_SETTINGS)}
>
Reset to defaults
</Button>
</PopoverContent>
</Popover>
<Button size="sm" onClick={addEvent}>
<PlusIcon className="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, custom chips via renderEvent, and a tabbed Settings panel driving view settings, time-grid options, and interactions as controlled props - with a reset back to defaults.
The event-calendar package separates the calendar engine from the calendar UI. A subscribable 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 selector hooks. 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
pnpm dlx shadcn@latest add @reui/event-calendar
Usage
import { EventCalendar } from "@/components/reui/event-calendar/event-calendar"import { EventCalendarContent } from "@/components/reui/event-calendar/event-calendar-content"import { EventCalendarNav } from "@/components/reui/event-calendar/event-calendar-nav"import type { CalendarEvent } from "@/components/reui/event-calendar/event-calendar-types"
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 (the examples use h-[560px]). 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 to your backend 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.
The root provider and container. It creates (or adopts) the calendar instance, provides it via context, and renders a flex-column div (customizable through render). Besides the props below, it accepts every option and callback listed under State options and Callbacks, and every display prop listed under View configuration.
Prop
Type
Default
Description
calendar
EventCalendarInstance<TData>
-
Adopt a hoisted useEventCalendarState instance; option props are then ignored (a dev warning fires if both are passed).
apiRef
RefObject<EventCalendarApi<TData> | null>
-
Imperative escape hatch; receives the instance API for use outside the tree.
children
ReactNode
-
Composed slots, typically EventCalendarNav, EventCalendarToolbar, and EventCalendarContent.
className
string
-
Additional CSS classes for the root element.
render
useRender render prop
-
Replace the rendered root 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 with useRender support.
Prop
Type
Default
Description
showViewSwitcher
boolean
true
Render the view switcher in the composed layout; turn off for fixed-view embeds.
children
ReactNode
-
Replaces the default composed layout entirely.
className
string
-
Additional CSS classes for the nav container.
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.
Prop
Type
Default
Description
tooltip
ReactNode | null
-
Hover/focus-visible hint. Today defaults to the current date; Prev/Next default to their i18n labels. Pass null to disable this one button.
children
ReactNode
-
Replaces the default label or chevron icon.
render
useRender render prop
-
Replace the rendered button element.
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".
Prop
Type
Default
Description
format
(ctx: { title: string }) => ReactNode
-
Wrap or transform the computed title text.
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.
Prop
Type
Default
Description
tooltip
ReactNode | null
-
Hover-only hint (force-closed while the menu is open); defaults to the "Select view" label.
children
ReactNode
-
Replaces the default trigger content (current view name + chevron).
EventCalendarDatePicker
Optional go-to-date popover (shadcn 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.
Prop
Type
Default
Description
mode
"auto" | "single" | "range"
"auto"
"auto" resolves to "range" for week/N-days/agenda and "single" otherwise.
tooltip
ReactNode | null
null
No tooltip by default (the button opens an overlay); pass a node to opt in.
children
ReactNode
-
Replaces the default calendar icon.
EventCalendarToolbar
Free slot for consumer toolbar buttons; a pure layout shell (flex items-center gap-2) with useRender support and 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.
Prop
Type
Default
Description
components
Partial<Record<CalendarView, ComponentType>>
-
Swap individual view implementations (merged over the root components prop).
children
ReactNode
-
Replaces the switchboard entirely; read useEventCalendarView() inside.
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.
Prop
Type
Default
Description
maxEventsPerCell
number | "auto"
-
Per-view override of the maxEventsPerCell view config (bars plus chips).
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.
Prop
Type
Default
Description
view
"week" | "day" | "days"
-
Required. Which time-based view this grid renders.
dayStartHour
number
-
Per-view override of the dayStartHour option.
dayEndHour
number
-
Per-view override of the dayEndHour option.
showAllDay
boolean
true
Render the all-day row above the time track.
interval
number
-
Gutter/gridline interval in minutes (clamped 5 to 240); defaults to the interval view config.
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 grouped by day: sticky date gutters, collapsible days with a color-dot summary row, expandable per-event details (via renderAgendaEventDetails), and a compact empty state. The agenda 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.
Prop
Type
Default
Description
dayStartHour
number
-
Per-view override of the dayStartHour option.
dayEndHour
number
-
Per-view override of the dayEndHour option.
showAllDay
boolean
true
Render the all-day row above the time track.
interval
number
-
Gutter/gridline interval in minutes (clamped 5 to 240); defaults to the interval view config.
EventCalendarApi
The imperative API, available as instance.api, through apiRef, or from any hook's instance. Reading methods reflect the current snapshot; writing methods respect controlled props (they call the matching on*Change callback and only mutate internal state for uncontrolled fields).
Method
Signature
Description
next / prev
() => void
Step the anchor date one period forward or backward for the current view.
today
() => void
Jump to now.
goTo
(date: Date) => void
Jump to a date.
setView
(view: CalendarView, opts?: { dayCount?: number }) => void
Switch view; unknown views fall back to the first enabled view with a dev warning.
The full rendered grid range (including outside days); fetch remote data for this.
getActiveRange
() => EventCalendarDateRange
The logical period (the month or week itself).
toZoned
(date: Date) => Date
The instant re-expressed in the calendar's display time zone (a TZDate).
scrollToTime
(time: Date | number) => void
Scroll a time grid to an instant or minutes-from-day-start; no-op outside time-grid views.
CalendarEvent
Your event objects. TData is a fully generic consumer payload.
Property
Type
Default
Description
id
string
-
Required. Stable event id.
title
string
-
Required. Display title.
start
Date
-
Required. Start instant (consumers parse ISO strings themselves).
end
Date
-
Required. Exclusive end instant; must be greater than or equal to start.
allDay
boolean
-
Render as an all-day bar; date comparison is day-granular in the display zone.
recurrence
EventCalendarRecurrenceRule | string
-
Structured rule or a raw "RRULE:..." line.
recurringEventId
string
-
Marks this event as an edited single occurrence of that series.
originalStart
Date
-
Which occurrence it replaces (RECURRENCE-ID semantics).
color
string
-
Token or CSS color; flows to the --ec-event-color CSS variable.
readOnly
boolean
-
Excluded from drag and resize regardless of the interactions state.
draggable
boolean
-
Per-event override; the default comes from interactions.drag.
resizable
boolean
-
Per-event override; the default comes from interactions.resize.
priority
number
-
Packing prominence; feeds the getEventPriority ordering.
zIndex
number
-
Explicit stacking override; wins over the computed z-index.
resourceId
string
-
Bookable resource this event belongs to (resource view).
data
TData
-
Consumer payload, fully generic.
EventCalendarResource
A bookable resource (room, person, equipment). Nested resources render their leaves as booking columns in the resource view.
Property
Type
Default
Description
id
string
-
Required. Stable resource id.
title
string
-
Required. Column header text (or renderResourceHeader).
color
string
-
Token or CSS color for subtle row/column accents.
children
EventCalendarResource[]
-
Child resources; only leaves become booking columns.
EventCalendarRecurrenceRule
The structured RFC 5545 subset. A raw RRULE string with the same parts is equally accepted on event.recurrence.
Property
Type
Default
Description
freq
"daily" | "weekly" | "monthly" | "yearly"
-
Required. Recurrence frequency.
interval
number
1
Every N periods.
count
number
-
Total number of occurrences.
until
Date
-
Inclusive end instant.
byWeekday
Array<EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }>
-
Weekdays; ordinals (2TU, -1FR) apply to monthly/yearly rules.
byMonthDay
number[]
-
Days of the month; negative values count back from month end.
byMonth
number[]
-
Months (1 to 12) for yearly rules.
weekStart
EventCalendarWeekday
-
WKST equivalent.
exDates
Date[]
-
Excluded instants; matching occurrences are removed (they still consume their count slot).
rDates
Date[]
-
Extra instants added to the series, each with the event's own duration.
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.
Property
Type
Description
key
string
Stable per instance: `${event.id}::${startISO}`.
eventId
string
The source event id.
event
CalendarEvent<TData>
The source event.
start / end
Date
This instance's instants (end exclusive).
allDay
boolean
Resolved all-day flag.
isRecurring
boolean
Whether it came from a recurrence expansion.
recurrenceIndex
number
Series ordinal, when recurring.
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.
Property
Type
Description
event
CalendarEvent<TData>
The event being changed.
occurrence
EventCalendarOccurrence<TData> | null
The dragged occurrence; null when source is "api".
start
Date
Proposed start.
end
Date
Proposed exclusive end.
allDay
boolean
Proposed all-day flag.
resourceId
string
Proposed resource when the gesture crossed resource columns.
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.
Property
Type
Description
date
Date
Clicked instant (snapped in time grids) or day (day-granular surfaces).
end
Date
Present for timed slots: date plus slotDuration.
allDay
boolean
Whether the click landed on a day-granular surface.
view
CalendarView
The view the click happened in.
resourceId
string
Present when the click happened inside a resource column.
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.
Property
Type
Description
start
Date
Draft start.
end
Date
Draft exclusive end.
allDay
boolean
Whether the draft was drawn on a day-granular surface.
view
CalendarView
The view the draft was drawn in.
resourceId
string
Present when the slot was selected inside a resource column.
EventCalendarState
The store snapshot, as received by useEventCalendarSelector selectors and instance.getState().
Full rendered grid range including outside days; fetch remote data for this.
activeRange
EventCalendarDateRange
The logical period (the month or week itself).
events
CalendarEvent<TData>[]
Current events.
selection
EventCalendarSelection
Selected occurrence keys plus the committed slot.
interactions
EventCalendarInteractions
Effective interaction toggles.
loading
boolean
The loading prop.
drag
EventCalendarDragState<TData> | null
Live move/resize gesture (kind, occurrence, proposed instants, validity), or null.
slotDraft
EventCalendarSlotDraft | null
Live drag-create rectangle, or null.
viewSettings
EventCalendarViewSettings
User display toggles.
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.
Headless root hook: the full engine without markup. Pass the instance to <EventCalendar calendar={instance}> or drive fully custom UI from getState / subscribe / api.
useEventCalendar
() => EventCalendarInstance<TData>
The stable calendar instance from context; throws outside <EventCalendar>.
The chip's subject; usable inside renderEvent content and chip children.
useNow
(intervalMs?: number) => Date
Current time, refreshed on an interval (default 30000 ms) and on tab focus.
An EventCalendarInstance is { getState, subscribe, api, settings, internals }; internals is cross-file plumbing for sibling view modules and not public API.
Helpers
Pure, React-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, and event-calendar.tsx exports the EventCalendarContext, EventCalendarViewConfigContext, and EventCalendarViewContext context objects for advanced composition.
Absolute top/height for a minute-positioned overlay block (from event-calendar-time-grid.tsx).
wasRecentDrag
() => boolean
True shortly after a gesture ended; lets click handlers ignore the click that ends a drag.
wasRecentChipPress
() => boolean
True shortly after a press started on an event chip; guards slot-create clicks.
markChipPress
() => void
Flag a chip press (called by EventCalendarEvent); needed when building custom chips.
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.
Parse a raw RRULE line (with or without the prefix); Z-less UNTIL values are wall time in the given zone.
formatRRuleString
(rule: EventCalendarRecurrenceRule) => string
Serialize the structured subset back to an RRULE line (without prefix).
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.
Prop
Type
Default
Description
events / defaultEvents
CalendarEvent<TData>[]
[]
The events (controlled / uncontrolled).
view / defaultView
CalendarView
"month"
Active view.
date / defaultDate
Date
new Date()
Anchor date.
dayCount / defaultDayCount
number
3
Day count for the days view (min 1).
selection / defaultSelection
EventCalendarSelection
{ eventKeys: [], slot: null }
Selection state.
interactions / defaultInteractions
Partial<EventCalendarInteractions>
all true
Interaction toggles, merged over the defaults.
viewSettings / defaultViewSettings
EventCalendarViewSettings
{}
User display toggles.
loading
boolean
false
Dims the content area and disables pointer events.
views
CalendarView[]
base views
Enabled views; defaults to ["month", "week", "day", "days", "agenda"], plus "resource" when resources is non-empty.
timeZone
string
system time zone
IANA display time zone; all day math and rendering happen in it.
locale
Locale
-
date-fns locale for every formatted string.
weekStartsOn
0 | 1 | 2 | 3 | 4 | 5 | 6
locale
First day of the week (0 = Sunday). Defaults to the locale's own first day when one is set, so a German or French calendar starts on Monday; an explicit value always wins.
dayStartHour
number
0
First rendered hour in time grids.
dayEndHour
number
24
Last rendered hour in time grids.
slotDuration
number
30
Duration in minutes of a click-created slot (onSlotClick end).
snapDuration
number
15
Snap granularity in minutes for drag, resize, and drag-create.
agendaDayCount
number
30
Days covered by the agenda view window.
fixedWeeks
boolean
true
Month view always renders 6 weeks.
showOutsideDays
boolean
true
Show leading/trailing outside days in the month view.
i18n
Partial<EventCalendarI18nConfig>
-
Per-key overrides of labels, view names, formats, and formatter functions.
resources
EventCalendarResource[]
[]
Bookable resources for the resource view.
getEventPriority
(event: CalendarEvent<TData>) => number
event.priority ?? 0
Packing prominence resolver.
eventOrder
(a, b: EventCalendarOccurrence<TData>) => number
defaultEventOrder
Occurrence sort comparator (default: earlier start, then longer duration, then key).
"+N more" clicked; return false to suppress the built-in popover and open your own UI.
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.
Prop
Type
Default
Description
scrollToHour
number
7
Hour the time grids scroll to on mount.
nowIndicator
boolean
true
Show the current-time line in time grids.
interval
number
60
Grid interval in minutes for time-based views; gutter slots and gridlines follow it. Also accepted per view component.
maxEventsPerCell
number | "auto"
"auto"
Max bars plus chips per month cell before "+N more".
showWeekNumbers
boolean
false
Week-number gutter in the month view.
enableShortcuts
boolean
true
View-switcher keyboard shortcuts and their kbd hints.
shortcutsScope
"focus-within" | "global"
"focus-within"
Where the shortcuts listen.
scrollMode
"contained" | "page"
"contained"
Contained: the calendar fills its container and views scroll internally. Page: content flows with the document and day headers stick below --ec-sticky-offset.
stickyNav
boolean
false
Stick the default nav to the top while the page scrolls.
dayClassName
(day: Date) => string | undefined
-
Custom per-day indication on month cells, day columns, and all-day cells.
todayClassName
string
-
Extra classes for the current day, appended after the built-in highlight.
showDayAddButton
boolean
false
Hover "+" affordance on month cells; fires the same onSlotClick as clicking the day.
scrollbars
"custom" | "native"
"custom"
Scroll implementation for every internally scrolling surface (shadcn ScrollArea vs browser scrollbars).
navButtonVariant
"ghost" | "outline" | "secondary" | "default"
"ghost"
Variant applied to all nav buttons.
navButtonSize
"sm" | "default"
"sm"
Size applied to all nav buttons (icon buttons use the icon twin).
offDays
boolean | EventCalendarOffDaysConfig
-
Off-day (non-working day) marking; true = weekends with a muted background.
classNames
EventCalendarClassNames
-
Per-element class hooks; see below.
components
Partial<Record<CalendarView, ComponentType>>
-
Swap individual view implementations.
dayCountPresets
number[]
[5]
N-day presets offered by the view switcher when the days view is enabled.
Replace the collapsed agenda day summary row content.
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, agendaDay, agendaDayGutter, agendaDate, agendaDayToggle, agendaDayContent, agendaItem, agendaItemSurface, agendaItemToggle, agendaDaySummary, agendaSummaryDot. 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.
Property
Type
Default
Description
drag
boolean
true
Move events by dragging.
resize
boolean
true
Resize events at their edges.
selectSlot
boolean
true
Drag-create slot selection.
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).
Property
Type
Default
Description
weekends
boolean
-
Show Saturday/Sunday columns in month, week, and N-day grids (effective default true).
weekNumbers
boolean
-
Week-number gutter in the month view (defers to showWeekNumbers).
nowIndicator
boolean
-
Current-time line (defers to the nowIndicator view config).
offDays
boolean
-
Off-day background marking (defers to the offDays view config).
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.
Property
Type
Default
Description
weekendDays
number[]
[0, 6]
Weekday numbers treated as off (0 = Sunday).
dates
Date[]
-
Additional explicit off dates (compared by day in the display zone).
isOffDay
(day: Date) => boolean
-
Full custom predicate; runs in addition to weekendDays / dates.
className
string
"bg-muted/40"
Marker classes.
Internationalization
The i18n option accepts a Partial<EventCalendarI18nConfig> with four sections, shallow-merged per nested object so a partial override replaces individual keys, never whole sections:
formats: date-fns format strings applied with the calendar locale (monthTitle, weekTitle, dayTitle, agendaTitle, monthDayHeader, monthDayHeaderNarrow, timeGridDayHeader, agendaDayHeader, agendaDayNumber, agendaWeekday, moreDayHeader, monthCellAriaLabel, dayAria, resourceTitle, timeGutter, timeGutterMinute, eventTime, monthCellDay). Leaving weekTitle / agendaTitle undefined 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 overriding formats.monthTitle flows into the default formatTitle without 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.
Shadcn Event Calendar Free Components
Browse 5 production-ready Shadcn Event Calendar components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
This primitive also powers ready-made ReUI Pro blocks: complete event calendar sections assembled on top of the exact component documented here, with realistic data, polished layout details, and production interaction patterns you can copy straight into your app.