Overview
MCP Server
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.
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.
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.
Browse all 5 Shadcn Event Calendar components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
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 6 Shadcn Event Calendar Pro blocks in the ReUI blocks gallery.
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.
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.
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.
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.
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.
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.
The current period title (from i18n.functions.formatTitle), marked aria-live="polite".
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.
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.
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.
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.
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.
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.
Thin wrapper around EventCalendarTimeGrid with view preset to "week". Accepts every EventCalendarTimeGrid prop except view.
Thin wrapper around EventCalendarTimeGrid with view preset to "day". Accepts every EventCalendarTimeGrid prop except view.
Thin wrapper around EventCalendarTimeGrid with view preset to "days". Accepts every EventCalendarTimeGrid prop except view.
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.
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.
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).
Your event objects. TData is a fully generic consumer payload.
A bookable resource (room, person, equipment). Nested resources render their leaves as booking columns in the resource view.
The structured RFC 5545 subset. A raw RRULE string with the same parts is equally accepted on event.recurrence.
EventCalendarWeekday is "MO" \| "TU" \| "WE" \| "TH" \| "FR" \| "SA" \| "SU". For rule parts outside this subset, plug a full RRULE engine through the getOccurrences option; unsupported parts throw EventCalendarRecurrenceError.
One expanded instance of an event, as passed to click callbacks and returned by api.getOccurrences.
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.
The payload of onEventUpdate and canDropEvent: a proposed timing (and optionally resource) change.
The onEventUpdate return type is EventCalendarUpdateResult: false rejects and reverts, void or true accepts, and { start?, end?, allDay? } accepts with an adjustment.
The payload of onSlotClick. A click is a point, not a range.
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.
The store snapshot, as received by useEventCalendarSelector selectors and instance.getState().
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.
Most hooks require an <EventCalendar> ancestor. The exceptions: useEventCalendarState creates the instance itself, useNow is standalone, useEventCalendarSettingsVersion takes the instance as an argument, and hooks that accept an explicit calendar option work outside the tree.
An EventCalendarInstance is { getState, subscribe, api, settings, internals }; internals is cross-file plumbing for sibling view modules and not public API.
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.
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).
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.
Option props accepted by EventCalendar and useEventCalendarState. Controlled/uncontrolled pairs follow the standard convention: pass the controlled prop plus its on*Change callback, or the default* prop for internal state.
EventCalendarActivationConfig defaults: moveDistancePx: 5, createDistancePx: 4, touchDelayMs: 250, touchTolerancePx: 5, autoScrollEdgePx: 48, autoScrollMaxStepPx: 15 (exported as EVENT_CALENDAR_ACTIVATION).
Callback props accepted by EventCalendar and useEventCalendarState, alongside the options above.
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.
Render overrides, all optional and all part of the same view config:
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]".
Interaction toggles, controllable via the interactions prop or api.setInteractions. All three default to true.
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).
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.
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.
"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>
)
}
agendaTitle