Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.1K
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Agent Skills
  • Changelog
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • v0
  • Lovable
  • Replit
  • OpenCode
  • VS Code
  • Zed
Components
  • Alert
  • Autocomplete
  • Badge
  • Data GridAutosizing, fill and resize hardening, React Compiler ready
  • Date Selector
  • Event CalendarNew event calendar component with five views
  • File Upload
  • FiltersSizing and interaction refinements
  • Frame
  • GanttNew gantt component with day to year scales
  • Icon Stack
  • KanbanonValueCommit callback and accessibility improvements
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • SortableonValueCommit callback and accessibility improvements
  • StepperRender prop composition and styling refinements
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Chart
  • Dashboard
  • Dialog
  • Empty State
  • Event CalendarNew event calendar block added
  • Form
  • GanttNew gantt block added
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Wizard

Solutions

  • Agents
  • AI Ops
  • Analytics
  • Billing
  • Bookings
  • CRM
  • Inventory
  • Users

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist

Data Grid

  • Base
  • Columns
  • Drag & Drop
  • EditingNew editable Data Grid block added
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Marketing

  • Blog
  • Contact
  • CTA
  • FAQ

Resources

  • Components
  • Blocks
  • Docs
  • Support
  • Pricing
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026ReUI · All rights reserved

3.1K

Shadcn Event Calendar

PreviousNext

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.

Base UIRadix UI
Radix UI

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

Shadcn Event Calendar Free Components

Browse 5 production-ready Shadcn Event Calendar components for dashboards, forms, and product UI. These examples follow the Radix UI implementation with accessible primitives from the Radix stack and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 5 Shadcn Event Calendar components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

Shadcn Event Calendar Pro Blocks

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.

Preview all 2 Shadcn Event Calendar Pro blocks in the ReUI blocks gallery.

Date SelectorFile Upload

On This Page

InstallationUsageAPI ReferenceEventCalendarEventCalendarNavEventCalendarNavTodayEventCalendarNavPrevEventCalendarNavNextEventCalendarTitleEventCalendarViewSwitcherEventCalendarDatePickerEventCalendarToolbarEventCalendarContentEventCalendarMonthViewEventCalendarTimeGridEventCalendarWeekViewEventCalendarDayViewEventCalendarDaysViewEventCalendarAgendaViewEventCalendarResourceViewEventCalendarApiCalendarEventEventCalendarResourceEventCalendarRecurrenceRuleEventCalendarOccurrenceEventCalendarSegmentEventCalendarProposedUpdateEventCalendarSlotInfoEventCalendarSlotDraftEventCalendarStateHooksHelpersRecurrence helpersConfigState optionsCallbacksView configurationEventCalendarInteractionsEventCalendarViewSettingsEventCalendarOffDaysConfigInternationalization
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"
const [events, setEvents] = useState<CalendarEvent[]>(initialEvents)
 
return (
  <EventCalendar
    events={events}
    onEventsChange={setEvents}
    defaultView="week"
    className="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 (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.

For more variations, browse the Event Calendar components.

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 (customizable through asChild). Besides the props below, it accepts every option and callback listed under State options and Callbacks, and every display prop listed under View configuration.

PropTypeDefaultDescription
calendarEventCalendarInstance<TData>-Adopt a hoisted useEventCalendarState instance; option props are then ignored (a dev warning fires if both are passed).
apiRefRefObject<EventCalendarApi<TData> | null>-Imperative escape hatch; receives the instance API for use outside the tree.
childrenReactNode-Composed slots, typically EventCalendarNav, EventCalendarToolbar, and EventCalendarContent.
classNamestring-Additional CSS classes for the root element.
asChildbooleanfalseRender the child element instead (Radix Slot).

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 asChild support.

PropTypeDefaultDescription
showViewSwitcherbooleantrueRender the view switcher in the composed layout; turn off for fixed-view embeds.
childrenReactNode-Replaces the default composed layout entirely.
classNamestring-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.

PropTypeDefaultDescription
tooltipReactNode | 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.
childrenReactNode-Replaces the default label or chevron icon.
asChildbooleanfalseRender the child element instead (Radix Slot).

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".

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
tooltipReactNode | null-Hover-only hint (force-closed while the menu is open); defaults to the "Select view" label.
childrenReactNode-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.

PropTypeDefaultDescription
mode"auto" | "single" | "range""auto""auto" resolves to "range" for week/N-days/agenda and "single" otherwise.
tooltipReactNode | nullnullNo tooltip by default (the button opens an overlay); pass a node to opt in.
childrenReactNode-Replaces the default calendar icon.

EventCalendarToolbar

Free slot for consumer toolbar buttons; a pure layout shell (flex items-center gap-2) with asChild 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.

PropTypeDefaultDescription
componentsPartial<Record<CalendarView, ComponentType>>-Swap individual view implementations (merged over the root components prop).
childrenReactNode-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.

PropTypeDefaultDescription
maxEventsPerCellnumber | "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.

PropTypeDefaultDescription
view"week" | "day" | "days"-Required. Which time-based view this grid renders.
dayStartHournumber-Per-view override of the dayStartHour option.
dayEndHournumber-Per-view override of the dayEndHour option.
showAllDaybooleantrueRender the all-day row above the time track.
intervalnumber-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.

PropTypeDefaultDescription
dayStartHournumber-Per-view override of the dayStartHour option.
dayEndHournumber-Per-view override of the dayEndHour option.
showAllDaybooleantrueRender the all-day row above the time track.
intervalnumber-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).

MethodSignatureDescription
next / prev() => voidStep the anchor date one period forward or backward for the current view.
today() => voidJump to now.
goTo(date: Date) => voidJump to a date.
setView(view: CalendarView, opts?: { dayCount?: number }) => voidSwitch view; unknown views fall back to the first enabled view with a dev warning.
setDayCount(count: number) => voidSet the N-day count (min 1).
getEvents() => CalendarEvent<TData>[]Current events array.
getEvent(id: string) => CalendarEvent<TData> | undefinedFind one event by id.
setEvents(events: CalendarEvent<TData>[]) => voidReplace the events array.
addEvent(event: CalendarEvent<TData>) => voidAppend an event.
updateEvent(id: string, patch: Partial<CalendarEvent<TData>>) => voidPatch an event; timing patches route through onEventUpdate with source: "api".
removeEvent(id: string) => voidRemove an event.
getOccurrences(range?: EventCalendarDateRange) => EventCalendarOccurrence<TData>[]Expanded, sorted occurrences; defaults to the visible range.
getOccurrencesForDay(day: Date) => EventCalendarOccurrence<TData>[]Deduplicated occurrences touching one day.
findOverlapping(candidate: { start: Date; end: Date; excludeEventId?: string }) => EventCalendarOccurrence<TData>[]Occurrences overlapping a candidate range.
select(selection: Partial<EventCalendarSelection>) => voidPatch the selection.
selectEvent(key: string, opts?: { additive?: boolean }) => voidSelect an occurrence key; additive toggles it within the current set.
clearSelection() => voidClear event keys and the committed slot.
setInteractions(patch: Partial<EventCalendarInteractions>) => voidToggle drag, resize, or slot selection.
setViewSettings(patch: EventCalendarViewSettings) => voidPatch the user display toggles.
getVisibleRange() => EventCalendarDateRangeThe full rendered grid range (including outside days); fetch remote data for this.
getActiveRange() => EventCalendarDateRangeThe logical period (the month or week itself).
toZoned(date: Date) => DateThe instant re-expressed in the calendar's display time zone (a TZDate).
scrollToTime(time: Date | number) => voidScroll 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.

PropertyTypeDefaultDescription
idstring-Required. Stable event id.
titlestring-Required. Display title.
startDate-Required. Start instant (consumers parse ISO strings themselves).
endDate-Required. Exclusive end instant; must be greater than or equal to start.
allDayboolean-Render as an all-day bar; date comparison is day-granular in the display zone.
recurrenceEventCalendarRecurrenceRule | string-Structured rule or a raw "RRULE:..." line.
recurringEventIdstring-Marks this event as an edited single occurrence of that series.
originalStartDate-Which occurrence it replaces (RECURRENCE-ID semantics).
colorstring-Token or CSS color; flows to the --ec-event-color CSS variable.
readOnlyboolean-Excluded from drag and resize regardless of the interactions state.
draggableboolean-Per-event override; the default comes from interactions.drag.
resizableboolean-Per-event override; the default comes from interactions.resize.
prioritynumber-Packing prominence; feeds the getEventPriority ordering.
zIndexnumber-Explicit stacking override; wins over the computed z-index.
resourceIdstring-Bookable resource this event belongs to (resource view).
dataTData-Consumer payload, fully generic.

EventCalendarResource

A bookable resource (room, person, equipment). Nested resources render their leaves as booking columns in the resource view.

PropertyTypeDefaultDescription
idstring-Required. Stable resource id.
titlestring-Required. Column header text (or renderResourceHeader).
colorstring-Token or CSS color for subtle row/column accents.
childrenEventCalendarResource[]-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.

PropertyTypeDefaultDescription
freq"daily" | "weekly" | "monthly" | "yearly"-Required. Recurrence frequency.
intervalnumber1Every N periods.
countnumber-Total number of occurrences.
untilDate-Inclusive end instant.
byWeekdayArray<EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }>-Weekdays; ordinals (2TU, -1FR) apply to monthly/yearly rules.
byMonthDaynumber[]-Days of the month; negative values count back from month end.
byMonthnumber[]-Months (1 to 12) for yearly rules.
weekStartEventCalendarWeekday-WKST equivalent.
exDatesDate[]-Excluded instants; matching occurrences are removed (they still consume their count slot).
rDatesDate[]-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.

PropertyTypeDescription
keystringStable per instance: `${event.id}::${startISO}`.
eventIdstringThe source event id.
eventCalendarEvent<TData>The source event.
start / endDateThis instance's instants (end exclusive).
allDaybooleanResolved all-day flag.
isRecurringbooleanWhether it came from a recurrence expansion.
recurrenceIndexnumberSeries 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.

PropertyTypeDescription
eventCalendarEvent<TData>The event being changed.
occurrenceEventCalendarOccurrence<TData> | nullThe dragged occurrence; null when source is "api".
startDateProposed start.
endDateProposed exclusive end.
allDaybooleanProposed all-day flag.
resourceIdstringProposed resource when the gesture crossed resource columns.
source"drag" | "resize-start" | "resize-end" | "keyboard" | "api"What produced the proposal.

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.

PropertyTypeDescription
dateDateClicked instant (snapped in time grids) or day (day-granular surfaces).
endDatePresent for timed slots: date plus slotDuration.
allDaybooleanWhether the click landed on a day-granular surface.
viewCalendarViewThe view the click happened in.
resourceIdstringPresent 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.

PropertyTypeDescription
startDateDraft start.
endDateDraft exclusive end.
allDaybooleanWhether the draft was drawn on a day-granular surface.
viewCalendarViewThe view the draft was drawn in.
resourceIdstringPresent when the slot was selected inside a resource column.

EventCalendarState

The store snapshot, as received by useEventCalendarSelector selectors and instance.getState().

PropertyTypeDescription
viewCalendarViewActive view ("month" | "week" | "day" | "days" | "agenda" | "resource").
dateDateAnchor date.
dayCountnumberDay count for the days view.
visibleRangeEventCalendarDateRangeFull rendered grid range including outside days; fetch remote data for this.
activeRangeEventCalendarDateRangeThe logical period (the month or week itself).
eventsCalendarEvent<TData>[]Current events.
selectionEventCalendarSelectionSelected occurrence keys plus the committed slot.
interactionsEventCalendarInteractionsEffective interaction toggles.
loadingbooleanThe loading prop.
dragEventCalendarDragState<TData> | nullLive move/resize gesture (kind, occurrence, proposed instants, validity), or null.
slotDraftEventCalendarSlotDraft | nullLive drag-create rectangle, or null.
viewSettingsEventCalendarViewSettingsUser 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.

HookSignatureDescription
useEventCalendarState(options?: UseEventCalendarStateOptions<TData>) => EventCalendarInstance<TData>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>.
useEventCalendarSelector(selector: (state: EventCalendarState<TData>) => T, options?: { calendar?, isEqual? }) => TFine-grained subscription with equality memoization (Object.is default).
useEventCalendarView() => { view, dayCount, availableViews, setView }Active view state plus the resolved views option.
useEventCalendarNavigation() => { date, title, visibleRange, activeRange, next, prev, today, goTo, isToday }Navigation state and actions; title is the formatted period title.
useEventCalendarSelection() => { selection, select, selectEvent, clearSelection }Selection state and actions.
useEventCalendarInteractions() => { interactions, setInteractions }Interaction toggles.
useEventCalendarOccurrences(range?: EventCalendarDateRange) => EventCalendarOccurrence<TData>[]Expanded, sorted occurrences; defaults to the visible range.
useEventCalendarDay(day: Date) => { segments: { allDay, timed }, isToday, isOutside }Per-cell subscription; only cells whose segments changed re-render.
useEventCalendarWeek(day: Date) => { bars, laneCount, rowStart }Per-week-row subscription for the month view's laned multi-day bars.
useEventCalendarViewSettings() => { viewSettings, setViewSettings, effective }User display toggles plus the effective values after view-config fallback.
useEventCalendarSettings() => EventCalendarSettings<TData>Resolved settings including merged i18n; re-renders only when settings change.
useEventCalendarSettingsVersion(instance: EventCalendarInstance<TData>) => numberSubscribes to settings changes only (version counter, not state).
useEventCalendarViewConfig() => EventCalendarViewConfig<TData>Root-level display props and render overrides, for view components.
useEventCalendarViewContext() => { view: CalendarView }The rendering view of the nearest view component; throws outside a view.
useEventCalendarGestures() => { beginMove, beginResize, beginCreate, canDrag, canResize }Per-chip / per-surface pointer gesture wiring for custom views and cells.
useEventCalendarEventChip() => { occurrence, segment, isDragging, isSelected }The chip's subject; usable inside renderEvent content and chip children.
useNow(intervalMs?: number) => DateCurrent 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.

FunctionSignatureDescription
flattenResources(resources: EventCalendarResource[], depth?: number) => Array<{ resource, depth }>Depth-first flatten of the resource tree, parents included.
buildEventIndex(events, visibleRange, opts) => EventCalendarIndex<TData>Expand, segment, and pack all events for a range into { occurrences, byDay, weekRows }.
segmentOccurrence(occurrence, range, timeZone) => EventCalendarSegment<TData>[]The canonical multi-day segmentation (exclusive end, zero-duration safe).
packTimedSegments(segments) => voidGoogle-style overlap packing for one day's timed segments (mutates column fields in place).
packWeekRowLanes(segments, rowIndex, rowStart, timeZone) => EventCalendarSegment<TData>[]Greedy lane packing of bar segments within one week row; returns new merged bar segments.
defaultEventOrder(a, b: EventCalendarOccurrence) => numberDefault sort: earlier start, then longer duration, then key.
getViewDateRange(view, date, opts) => { visibleRange, activeRange }The rendered and logical ranges for a view and anchor date.
stepDate(view, date, direction, opts) => DateThe anchor date stepped one period forward or backward.
toZoned(date: Date, timeZone: string) => TZDateThe instant re-expressed in the display time zone.
zonedStartOfDay(date: Date, timeZone: string) => TZDateZoned midnight of the day containing the instant.
getDayKey(date: Date, timeZone: string) => stringStable per-day key (yyyy-MM-dd) in the display time zone.
getDayTotalMinutes(dayStart: Date, timeZone: string) => numberDay length in minutes; 1380/1500 on DST transition days.
getRangeKey(range: EventCalendarDateRange) => stringCheap string cache key for a range.
snapMinutes(minutes: number, snap: number) => numberRound minutes to the nearest snap step.
rangesIntersect(a, b: EventCalendarDateRange) => booleanHalf-open range intersection.
eventsOverlap(a, b: { start: Date; end: Date }) => booleanHalf-open overlap test.
isBarOccurrence(occurrence: EventCalendarOccurrence) => booleanTrue when the occurrence renders as a bar (all-day or multi-day).
spansMultipleDays(occ: { start: Date; end: Date }) => booleanLonger than 24 hours (an event ending exactly at midnight is single-day).
resolveOffDay(day, timeZone, config: boolean | EventCalendarOffDaysConfig | undefined) => booleanWhether a day is an off day in the display zone.
minuteBlockStyle(startMin, endMin, boundsStartMin) => CSSPropertiesAbsolute top/height for a minute-positioned overlay block (from event-calendar-time-grid.tsx).
wasRecentDrag() => booleanTrue shortly after a gesture ended; lets click handlers ignore the click that ends a drag.
wasRecentChipPress() => booleanTrue shortly after a press started on an event chip; guards slot-create clicks.
markChipPress() => voidFlag 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.

FunctionSignatureDescription
expandRecurrence(event, range, ctx: { timeZone }) => EventCalendarOccurrence<TData>[]Expand one event into its occurrences intersecting the range; wall-time iteration in the display zone (DST-safe).
parseRRuleString(input: string, timeZone?: string) => EventCalendarRecurrenceRuleParse a raw RRULE line (with or without the prefix); Z-less UNTIL values are wall time in the given zone.
formatRRuleString(rule: EventCalendarRecurrenceRule) => stringSerialize 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.

PropTypeDefaultDescription
events / defaultEventsCalendarEvent<TData>[][]The events (controlled / uncontrolled).
view / defaultViewCalendarView"month"Active view.
date / defaultDateDatenew Date()Anchor date.
dayCount / defaultDayCountnumber3Day count for the days view (min 1).
selection / defaultSelectionEventCalendarSelection{ eventKeys: [], slot: null }Selection state.
interactions / defaultInteractionsPartial<EventCalendarInteractions>all trueInteraction toggles, merged over the defaults.
viewSettings / defaultViewSettingsEventCalendarViewSettings{}User display toggles.
loadingbooleanfalseDims the content area and disables pointer events.
viewsCalendarView[]base viewsEnabled views; defaults to ["month", "week", "day", "days", "agenda"], plus "resource" when resources is non-empty.
timeZonestringsystem time zoneIANA display time zone; all day math and rendering happen in it.
localeLocale-date-fns locale for every formatted string.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 60First day of the week (0 = Sunday).
dayStartHournumber0First rendered hour in time grids.
dayEndHournumber24Last rendered hour in time grids.
slotDurationnumber30Duration in minutes of a click-created slot (onSlotClick end).
snapDurationnumber15Snap granularity in minutes for drag, resize, and drag-create.
agendaDayCountnumber30Days covered by the agenda view window.
fixedWeeksbooleantrueMonth view always renders 6 weeks.
showOutsideDaysbooleantrueShow leading/trailing outside days in the month view.
i18nPartial<EventCalendarI18nConfig>-Per-key overrides of labels, view names, formats, and formatter functions.
resourcesEventCalendarResource[][]Bookable resources for the resource view.
getEventPriority(event: CalendarEvent<TData>) => numberevent.priority ?? 0Packing prominence resolver.
eventOrder(a, b: EventCalendarOccurrence<TData>) => numberdefaultEventOrderOccurrence sort comparator (default: earlier start, then longer duration, then key).
getOccurrences(event, range, ctx: { timeZone }) => Array<{ start: Date; end: Date }> | null-Custom recurrence expansion per event (plug a full RRULE engine); return null for the built-in one.
weekendDaysnumber[][0, 6]Weekday numbers treated as the weekend by the "weekends" view toggle.
activationPartial<EventCalendarActivationConfig>see belowPointer-gesture tuning.

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.

PropSignatureDescription
onEventClick(occurrence, e: React.MouseEvent) => voidChip click; call e.preventDefault() to opt out of the built-in selection.
onEventDoubleClick(occurrence, e: React.MouseEvent) => voidChip double click.
onEventUpdate(update: EventCalendarProposedUpdate<TData>) => EventCalendarUpdateResultThe one validation funnel for drags, resizes, and API timing changes; false rejects, an object adjusts.
canDropEvent(update: EventCalendarProposedUpdate<TData>) => booleanLive validation during a gesture; drives the data-drop-invalid styling and the not-allowed cursor.
onDragBlocked(occurrence, info: { gesture: "move" | "resize"; reason: "readOnly" | "disabled" | "interactions-off" }) => voidA gesture was attempted on a locked event; fires once per gesture so you can surface a message.
onSlotClick(slot: EventCalendarSlotInfo, e: React.MouseEvent) => voidClick on an empty slot or day cell (also fired by the day add button).
onSelectSlot(slot: EventCalendarSlotDraft) => voidA drag-create selection was committed.
canSelectSlot(slot: EventCalendarSlotDraft) => booleanLive validation of the drag-create rectangle.
onRangeChange(info: EventCalendarRangeInfo) => voidVisible range changed (view, date, or time zone); fires once for the initial range. info carries range, activeRange, view, date, and timeZone.
onViewChange(view: CalendarView) => voidView changed.
onDateChange(date: Date) => voidAnchor date changed.
onDayCountChange(count: number) => voidN-day count changed.
onSelectionChange(selection: EventCalendarSelection) => voidSelection changed.
onInteractionsChange(interactions: EventCalendarInteractions) => voidInteraction toggles changed.
onViewSettingsChange(viewSettings: EventCalendarViewSettings) => voidUser display toggles changed.
onEventsChange(events: CalendarEvent<TData>[]) => voidEvents array changed (drag commit, resize, API mutation).
onMoreClick(day: Date, occurrences: EventCalendarOccurrence<TData>[], e: React.MouseEvent) => void | false"+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.

PropTypeDefaultDescription
scrollToHournumber7Hour the time grids scroll to on mount.
nowIndicatorbooleantrueShow the current-time line in time grids.
intervalnumber60Grid interval in minutes for time-based views; gutter slots and gridlines follow it. Also accepted per view component.
maxEventsPerCellnumber | "auto""auto"Max bars plus chips per month cell before "+N more".
showWeekNumbersbooleanfalseWeek-number gutter in the month view.
enableShortcutsbooleantrueView-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.
stickyNavbooleanfalseStick 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.
todayClassNamestring-Extra classes for the current day, appended after the built-in highlight.
showDayAddButtonbooleanfalseHover "+" 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).
offDaysboolean | EventCalendarOffDaysConfig-Off-day (non-working day) marking; true = weekends with a muted background.
classNamesEventCalendarClassNames-Per-element class hooks; see below.
componentsPartial<Record<CalendarView, ComponentType>>-Swap individual view implementations.
dayCountPresetsnumber[][5]N-day presets offered by the view switcher when the days view is enabled.
navTooltipsfalse | { side?, delay?, closeDelay?, timeout? }{ side: "bottom", delay: 600, closeDelay: 0, timeout: 300 }Nav tooltips: false disables them all; the object tunes placement and timings.
eventTooltipboolean | { side?, delay? }falseStyled tooltip on event hover / focus; true shows the event label, an object tunes side and delay. Content via renderEventTooltip.
compactEventMinutesnumber45Timed events shorter than this render the compact single-row chip layout.
morePopoverAlign"start" | "center" | "end""start""+N more" popover alignment against its trigger.
nowIndicatorIntervalnumber30000Now-indicator refresh cadence in milliseconds.
agendaSummaryMaxDotsnumber6Max color dots in a collapsed agenda day summary.

Render overrides, all optional and all part of the same view config:

PropSignatureDescription
renderEvent(props: EventCalendarRenderEventProps<TData>) => ReactNodeReplace the chip content in grid views; receives occurrence, segment, view, isDragging, isSelected.
renderAgendaEvent(props: EventCalendarRenderEventProps<TData>) => ReactNodeReplace the agenda row content.
renderEventTooltip(props: { occurrence, segment, view, label }) => ReactNodeContent for the styled hover tooltip (eventTooltip); a falsy return falls back to the default label.
renderDragPreview(props: { drag: EventCalendarDragState<TData> }) => ReactNodeCustom drag preview content.
renderMonthCell(props: { day, segments, isToday, isOutside, overflowCount, defaultContent }) => ReactNodeReplace a month cell's body; defaultContent lets you wrap instead of rebuild.
renderDayColumnBackground(props: { day, boundsStartMin, boundsEndMin, totalMinutes }) => ReactNodeBusiness-logic layer rendered pointer-events-none behind event segments in each day column.
renderDayHeader(props: { day, view, isToday }) => ReactNodeReplace day header cells (month header row and time-grid headers).
renderTimeGutterSlot(props: { time, hour, minute }) => ReactNodeReplace hour gutter labels.
renderAllDaySection(props: { days, segments }) => ReactNodeReplace the entire all-day row of time grids.
renderMoreIndicator(props: { day, count, segments }) => ReactNodeReplace the "+N more" trigger content.
renderMoreContent(props: { day, segments, close }) => ReactNodeReplace the entire body of the built-in "+N more" popover while keeping its trigger and positioning.
renderAgendaEventDetails(occurrence: EventCalendarOccurrence<TData>) => ReactNodeAgenda-only expandable details; returning a node gives the row an expand/collapse toggle.
renderNowIndicator(props: { time: Date }) => ReactNodeReplace the current-time line.
renderNoEvents() => ReactNodeReplace the agenda empty state.
renderResourceHeader(props: { resource: EventCalendarResource }) => ReactNodeResource column header content; default is resource.title.
renderAgendaDayHeader(props: { day, collapsed, count, toggle, defaultContent }) => ReactNodeReplace the agenda date gutter (day badge, weekday, collapse toggle).
renderAgendaDaySummary(props: { day, occurrences, count, expand, defaultContent }) => ReactNodeReplace 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.

PropertyTypeDefaultDescription
dragbooleantrueMove events by dragging.
resizebooleantrueResize events at their edges.
selectSlotbooleantrueDrag-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).

PropertyTypeDefaultDescription
weekendsboolean-Show Saturday/Sunday columns in month, week, and N-day grids (effective default true).
weekNumbersboolean-Week-number gutter in the month view (defers to showWeekNumbers).
nowIndicatorboolean-Current-time line (defers to the nowIndicator view config).
offDaysboolean-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.

PropertyTypeDefaultDescription
weekendDaysnumber[][0, 6]Weekday numbers treated as off (0 = Sunday).
datesDate[]-Additional explicit off dates (compared by day in the display zone).
isOffDay(day: Date) => boolean-Full custom predicate; runs in addition to weekendDays / dates.
classNamestring"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:

  • 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 calendar locale (monthTitle, weekTitle, dayTitle, agendaTitle, monthDayHeader, monthDayHeaderNarrow, timeGridDayHeader, agendaDayHeader, agendaDayNumber, agendaWeekday, moreDayHeader, monthCellAriaLabel, dayAria, resourceTitle, timeGutter, timeGutterMinute, eventTime, monthCellDay). Leaving weekTitle / 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.

agendaTitle
"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 { EventCalendarI18nConfig } 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: Partial<EventCalendarI18nConfig> | 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">
          <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">
          <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 asChild>
                    <Button variant="outline" size="sm">
                      <Settings2Icon  className="size-4" aria-hidden="true" />
                      Settings
                    </Button>
                  </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>
  )
}