Event Calendar
A full scheduling surface — month, week, day, N-days, agenda and resource views — with drag, resize, recurrence and a headless engine underneath.
Overview
The event calendar is the kit's largest component: a headless scheduling engine
with a Cupido/MD3 shell on top. The engine owns the hard parts — controlled and
uncontrolled state, recurrence expansion, time-zone-correct day math, lane
packing for overlapping events, pointer drag/resize with snapping — and never
renders anything. Everything you see is built from components you already
have: Button and
IconButton for the nav,
DropdownMenu for the view switcher,
Popover +
Calendar for go-to-date and the "+N more"
list, Tooltip for the hints,
Kbd for the shortcut caps and
ScrollArea for every internally
scrolling surface.
Colors are MD3 roles only. The long grid rules use the hairline step (a
softened outline-variant) rather than a component border, so a 7 × 6 month
grid reads as structure instead of a cage; today is the same primary outline
ring Calendar draws, so a date means the
same thing in both; and the now-line is error, the one place in the system
that colour is used for "right now".
$ npx morpha-ui add event-calendarComposition
EventCalendar is a provider plus a container. It renders nothing on its own —
drop the nav and the content switchboard inside it, in whatever order your
layout wants:
<EventCalendar events={events} onEventsChange={setEvents}>
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>EventCalendarNav composes a default toolbar (Today · prev/next · the period ·
the view switcher). Pass children instead and it becomes a plain layout shell,
so you can reorder the parts, drop one, or slot in your own buttons:
<EventCalendarNav>
<EventCalendarNavToday />
<EventCalendarNavPrev />
<EventCalendarNavNext />
<EventCalendarTitle />
<div className="grow" />
<EventCalendarDatePicker />
<EventCalendarViewSwitcher />
</EventCalendarNav>EventCalendarContent renders the view the calendar is currently on. Swap one
implementation with components={{ month: MyMonthView }}, or replace the whole
switchboard by passing children and reading useEventCalendarView() yourself.
Examples
Week
The time grid backs week, day and the N-days view: a fixed gutter, an
all-day lane above the scroll region, and columns whose gridlines follow
interval. dayStartHour/dayEndHour crop the day, scrollToHour decides
where it opens, and offDays shades the weekend with an 8-per-cent state
layer instead of a second border.
Day
Everything is interactive by default: drag a chip to move it, grab its top or
bottom edge to resize, or draw on empty time to create. Moves are carried by a
cursor-attached clone while a faint dashed placeholder marks the snapped slot —
so you always see both where the event came from and where it will land.
Rejected drops get the error treatment and a not-allowed cursor.
Agenda
A flat, read-only list grouped by day, with a sticky surface-container day
bar. Rows never select — they only hover — because an agenda is for reading,
not arranging. An empty window falls back to the illustrated empty state.
Resources
Pass resources and the calendar gains a resource view: one booking column
per leaf resource for the anchor day. Events join a column through
resourceId, and dragging one across columns rebooks it.
Event colors
event.color is any CSS color, exposed to the chip as --ec-event-color and
used for the tint, the inset ring, the dot and every drag ghost. Left unset it
falls back to the brand primary. EVENT_CALENDAR_COLORS ships ten presets:
the design system's own purple, blue and red (the Cupido palettes at step 500)
plus seven extra hues, all mid-tones so the 15–20 per cent tint stays legible
on both the light and the dark surface.
Installation
Morpha UI components are distributed via the Morpha CLI: npx morpha-ui add event-calendar. It pulls in the button, icon-button, calendar,
popover, dropdown-menu, tooltip, scroll-area and kbd items it
composes.
npx morpha-ui add event-calendarUsage
import {
EventCalendar,
EventCalendarContent,
EventCalendarNav,
type CalendarEvent,
} from "@/components/ui/event-calendar"const [events, setEvents] = React.useState<CalendarEvent[]>([
{
id: "1",
title: "Design standup",
start: new Date(2026, 6, 27, 9, 30),
end: new Date(2026, 6, 27, 10, 0),
color: "var(--color-primary-500)",
},
])
return (
<div className="h-[560px] overflow-hidden rounded-xl border border-outline-variant">
<EventCalendar
events={events}
onEventsChange={setEvents}
defaultView="week"
weekStartsOn={1}
className="h-full"
>
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
</div>
)The calendar fills its container and scrolls internally, so give it a height.
onEventsChange fires with the whole list after any internal mutation — a
drag, a resize, or an api.* call — which is all you need to keep it
controlled. For scrollMode="page", the views flow with the document instead
and the page scrolls.
Fetching a window
onRangeChange fires once per visible period with the range, the active range
and the display time zone — the hook for loading only the events you need:
<EventCalendar
events={events}
loading={isPending}
onRangeChange={({ range }) => fetchEvents(range.start, range.end)}
/>loading dims the grid and swallows pointer events while the fetch is in
flight, so a half-loaded month can't be dragged around.
Rejecting a drop
onEventUpdate runs before a move or resize commits. Return false to reject
it, or a patched { start, end, allDay } to adjust it — the calendar applies
whatever comes back:
<EventCalendar
onEventUpdate={({ start, end }) =>
start.getHours() < 9 ? false : { start, end }
}
canDropEvent={({ start }) => start.getDay() !== 0}
/>canDropEvent is the live version: it runs on every pointer move, and a
false marks the ghost in error and shows the not-allowed cursor before the
user commits.
Driving it from outside
Hoist the engine with useEventCalendarState() and hand the instance to the
component. Option props are then ignored — the instance owns everything — and
instance.api is available anywhere, including outside the tree:
const calendar = useEventCalendarState({ defaultEvents: events })
return (
<>
<Button onClick={() => calendar.api.setView("agenda")}>Agenda</Button>
<EventCalendar calendar={calendar}>
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
</>
)Inside the tree, the same handles come from hooks:
useEventCalendarNavigation() (title, next/prev/today/goTo),
useEventCalendarView(), useEventCalendarSelection(),
useEventCalendarOccurrences() and useEventCalendarDay(day). Each subscribes
to its own slice, so a nav button re-render never touches the grid.
API Reference
EventCalendar
The root: provider, container, and the surface the calendar paints itself on
(bg-surface, stepping aside inside a card or popover). Every option below is
a prop on this one element.
State & data
Prop
Type
Each controlled prop has an uncontrolled default* twin (defaultEvents,
defaultView, defaultDate, defaultSelection, defaultInteractions).
Display
Prop
Type
Beyond renderEvent and renderMonthCell, every region has a render override:
renderAgendaEvent, renderEventTooltip, renderDayHeader,
renderTimeGutterSlot, renderAllDaySection, renderDayColumnBackground,
renderMoreIndicator, renderMoreContent, renderNowIndicator,
renderNoEvents, renderResourceHeader and renderDragPreview.
Callbacks
Prop
Type
CalendarEvent
Prop
Type
Parts
| Part | Notes |
|---|---|
EventCalendarNav | Composed toolbar, or a layout shell when given children. showViewSwitcher={false} pins the view. |
EventCalendarNavToday · NavPrev · NavNext | The three navigation controls. Each takes tooltip (a node, or null to silence it). |
EventCalendarTitle | The aria-live period label; format rewrites it. |
EventCalendarViewSwitcher | DropdownMenu of the enabled views, with Kbd shortcut caps. |
EventCalendarDatePicker | Optional go-to-date Popover + Calendar; mode="auto" highlights a range in the range-shaped views. |
EventCalendarToolbar | Empty slot for your own actions. |
EventCalendarContent | Renders the active view; components swaps individual views. |
EventCalendarMonthView · WeekView · DayView · DaysView · AgendaView · ResourceView | The views, usable directly when the calendar is locked to one. |
EventCalendarEvent | The one interactive chip every view renders. |
Styling hooks
classNames reaches ~50 named elements (nav, monthCell, timeGrid,
timedChip, dragGhost, agendaItem, …) and doubles as the place to set the
metric CSS variables, since they cascade:
| Variable | Default | Controls |
|---|---|---|
--ec-hour-height | 4rem | Height of one hour in the time grid |
--ec-gutter-width | 4.5rem | Time-gutter width |
--ec-month-bar-h | 1.75rem | Height of one month/all-day lane |
--ec-month-row-min-h | 8rem | Minimum month row height in page mode |
--ec-event-min-h | 1.5rem | Minimum height of a timed chip |
--ec-more-max-height | 16rem | "+N more" popover scroll cap |
--ec-slot-line-color | hairline | Gridline colour |
--ec-event-color | primary | Per-chip accent, set from event.color |
<EventCalendar classNames={{ timeGrid: "[--ec-hour-height:5rem]" }} />Accessibility
- The month view is a real
gridwithrow/columnheader/gridcellroles; the time and resource views expose each column as a labelledgroup, and the agenda groups by day withrole="heading"day bars so screen-reader users can jump day to day. - Every chip is a button carrying the event's title and time, plus a
"continues" note when it is one segment of a longer event. In interactive
views it is also a toggle, so selection is announced through
aria-pressedrather than by the tint alone. - Keyboard shortcuts switch views (M, W, D,
A, and the N-days digit). They are scoped to focus-within by
default;
shortcutsScope="global"widens them,enableShortcuts={false}turns them off. - Drag and resize are pointer gestures with no keyboard equivalent — keep an
editing path that does not need one (a dialog on
onEventClick, a form) if rescheduling matters to your users. - Committing a move re-keys the chip, which would normally drop focus to the document; the calendar hands focus back to the chip's replacement.
- The period title is
aria-live="polite", so paging announces the new range. - Today is marked by an outline ring and the
data-todayattribute, never by colour alone; the same holds for off days (data-off) and rejected drops, which add a cursor and a text hint next to theerrortint. - All motion — the popup transitions, the chevron flip, the state layers —
respects
prefers-reduced-motion.