Event Calendar

Month, week and day views of a schedule. Drag an event to another slot, pull an edge to change its length, or do both from the keyboard.

1 AM2 AM3 AM4 AM5 AM6 AM7 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM7 PM8 PM9 PM10 PM11 PM

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add event-calendar

Usage#

usage.tsxTSX
import { EventCalendar, type CalendarEvent } from "@/components/ui/event-calendar"const initial: CalendarEvent[] = [  { id: "1", title: "Standup", start: new Date(2026, 9, 12, 9, 30), end: new Date(2026, 9, 12, 9, 45), color: "neutral" },  { id: "2", title: "Offsite", start: new Date(2026, 9, 19), end: new Date(2026, 9, 21), allDay: true, color: "info" },]export function Schedule() {  const [events, setEvents] = React.useState(initial)  return (    <EventCalendar      events={events}      onEventChange={(next) => setEvents((all) => all.map((ev) => (ev.id === next.id ? next : ev)))}    />  )}

Anatomy#

import {  EventCalendar,  EventCalendarHeader,  EventCalendarTitle,  EventCalendarNav,  EventCalendarViewSwitch,  EventCalendarBody,} from "@/components/ui/event-calendar"<EventCalendar>  <EventCalendarHeader>    <EventCalendarTitle />    <EventCalendarNav />    <EventCalendarViewSwitch />  </EventCalendarHeader>  <EventCalendarBody /></EventCalendar>

Drag, resize and keys#

In the week and day grid, drag an event to another time or day, or pull its top or bottom edge to change its length, in steps of snapMinutes. Events that overlap sit side by side. The event in hand lifts and glides from step to step, a dashed outline marks where it came from, the events around it stay put, and the grid scrolls when you reach its edge. Escape puts it back. A press without movement is still a click. Nothing changes until you let go: then onEventChange gets the updated event, and you write it back into events. With an event focused, the arrow keys do the same: up and down move it by snapMinutes, left and right by a day, and Shift with up or down changes its length. Click empty space to create an event through onSlotClick.

Month#

Timed events show as a dot, a time and a title; all-day events as a tinted bar, listed first. A day with more than maxEventsPerDay ends in "+N more", which opens that day. Drag an event to another cell, or move it with the arrow keys: a day sideways, a week up or down. Click a day number to open that day.

Day#

One column fits a sidebar or a phone. Pass a single entry to views and the view switch goes away.

1 AM2 AM3 AM4 AM5 AM6 AM7 AM8 AM9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM7 PM8 PM9 PM10 PM11 PM

Read only#

editable={false} turns off dragging and the keys; events still open through onEventClick. The header is made of parts: this one leaves out the view switch. startHour, endHour and hourHeight fit a working day into a shorter card.

9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM6 PM7 PM

Click an event to open it.

Colours#

color tints the block and its bar with one of the theme's roles, so events stay readable in all four themes. Without it, an event uses brand.

9 AM10 AM11 AM12 PM1 PM2 PM3 PM4 PM5 PM

API reference#

EventCalendar

The card holding the schedule, 680px tall (an h-* class sets another height). With no children it renders EventCalendarHeader and EventCalendarBody. Forwards all native <div> props, and every part accepts className, merged last.

PropTypeDefaultDescription
events*CalendarEvent[]-{ id, title, start, end, allDay?, color? }. An all-day event's end is its last day, inclusive.
onEventChange(event: CalendarEvent) => void-Called with the updated event when a drag, a resize or a key moves it. Write it back into events.
onEventClick(event: CalendarEvent) => void-Called on a click, or Enter on a focused event. Never after a drag.
onSlotClick(slot: CalendarSlot) => void-Called when empty space is clicked: a snapMinutes × 2 slot in the time grid, a whole day in the month grid.
view"month" | "week" | "day"-The view on show. Controlled.
defaultView"month" | "week" | "day""week"The view it opens on. Uncontrolled.
onViewChange(view) => void-Called when the switch, a day heading or "+N more" changes the view.
dateDate-Any day inside the period on show. Controlled.
defaultDateDatetodayThe day it opens on. Uncontrolled; without it, the calendar opens on the browser's today.
onDateChange(date: Date) => void-Called when the reader navigates.
viewsEventCalendarView[]["month", "week", "day"]The views the switch offers. One view hides the switch.
weekStartsOn0–61The first day of the week, 0 for Sunday.
localestringthe browser'sBCP 47 locale for every label.
editablebooleantrueEvents can be dragged, resized and moved with the keyboard.
snapMinutesnumber15Drags, resizes and keys move in steps of this many minutes.
hourHeightnumber48The height of one hour in the time grid, in px.
startHournumber0The first hour the time grid shows.
endHournumber24The hour the time grid ends at.
scrollToHournumber8The hour the time grid opens scrolled to.
maxEventsPerDaynumber3Rows a month cell fills before the last one becomes "+N more".
Data attributeDescription
data-viewmonth, week or day.

EventCalendarHeader

The bar above the grid. With no children: title, navigation and the view switch. Forwards all native <div> props.

EventCalendarTitle

An <h2> naming the period, the year in the muted ink, announced politely when it changes. Forwards all native <h2> props.

EventCalendarNav

Today, previous and next: an outline Button and two ghost icon buttons. Forwards all native <div> props.

PropTypeDefaultDescription
todayLabelstring"Today"The label of the button that returns to today.

EventCalendarViewSwitch

A segmented ToggleGroup over the root's views; renders nothing with one view. Forwards ToggleGroup props.

PropTypeDefaultDescription
labelsPartial<Record<EventCalendarView, string>>-The label of each view.

EventCalendarBody

The grid for the current view. A new period fades and rises in once the reader navigates, never on load. Forwards all native <div> props.

FAQ#

No. It works on plain Date objects in the browser's time zone and formats every label with Intl, so it adds nothing to your bundle. Bring date-fns or Temporal for your own logic if you want; the calendar only reads start and end.