Morpha UI
UI KitFavouritesPipeline
Morpha UI

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-calendar

Composition

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-calendar

Usage

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

PartNotes
EventCalendarNavComposed toolbar, or a layout shell when given children. showViewSwitcher={false} pins the view.
EventCalendarNavToday · NavPrev · NavNextThe three navigation controls. Each takes tooltip (a node, or null to silence it).
EventCalendarTitleThe aria-live period label; format rewrites it.
EventCalendarViewSwitcherDropdownMenu of the enabled views, with Kbd shortcut caps.
EventCalendarDatePickerOptional go-to-date Popover + Calendar; mode="auto" highlights a range in the range-shaped views.
EventCalendarToolbarEmpty slot for your own actions.
EventCalendarContentRenders the active view; components swaps individual views.
EventCalendarMonthView · WeekView · DayView · DaysView · AgendaView · ResourceViewThe views, usable directly when the calendar is locked to one.
EventCalendarEventThe 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:

VariableDefaultControls
--ec-hour-height4remHeight of one hour in the time grid
--ec-gutter-width4.5remTime-gutter width
--ec-month-bar-h1.75remHeight of one month/all-day lane
--ec-month-row-min-h8remMinimum month row height in page mode
--ec-event-min-h1.5remMinimum height of a timed chip
--ec-more-max-height16rem"+N more" popover scroll cap
--ec-slot-line-colorhairlineGridline colour
--ec-event-colorprimaryPer-chip accent, set from event.color
<EventCalendar classNames={{ timeGrid: "[--ec-hour-height:5rem]" }} />

Accessibility

  • The month view is a real grid with row/columnheader/gridcell roles; the time and resource views expose each column as a labelled group, and the agenda groups by day with role="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-pressed rather 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-today attribute, 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 the error tint.
  • All motion — the popup transitions, the chevron flip, the state layers — respects prefers-reduced-motion.

On this page