Calendar
An enterprise week view. Pass a week of days and a flat list of events; it draws the header, an optional all-day band, the hourly grid and a now rule, and packs overlapping events into side-by-side columns so a busy day still reads. Interaction layers on from a client boundary: renderSlotPopover opens an anchored popover to add an event on an empty slot, renderEventPopover opens one on an existing event, and onEventDrop enables drag-to-reschedule — hold Ctrl (Cmd on macOS) while dragging to copy. Try it below: drag an event to move it, or hold Ctrl/Cmd to drop a copy. The live version is on the Calendar showcase.
UTC+7
Mon18
Tue194
Wed20
Thu21
Fri22
All day
On-call
Team offsite
1 AM
2 AM
3 AM
4 AM
5 AM
6 AM
7 AM
8 AM
9 AM
10 AM
11 AM
12 PM
1 PM
2 PM
3 PM
4 PM
5 PM
6 PM
7 PM
8 PM
9 PM
10 PM
11 PM
12 PM
const days = [ { weekday: 'Mon', date: 18 }, { weekday: 'Tue', date: 19, today: true, count: 4 }, { weekday: 'Wed', date: 20 }, { weekday: 'Thu', date: 21 }, { weekday: 'Fri', date: 22 },];
const events = [ // All-day band. { id: 'offsite', title: 'Team offsite', day: 4, allDay: true, tone: 'info' }, { id: 'oncall', title: 'On-call', day: 1, allDay: true, tone: 'warning' },
// Mon. { id: 'm1', title: 'Standup', day: 0, start: '09:00', end: '09:15', tone: 'alt' }, { id: 'm2', title: 'Design review', day: 0, start: '09:30', end: '11:00', tone: 'info', meta: 'Figma' }, { id: 'm3', title: '1:1 Priya', day: 0, start: '11:30', end: '12:00', tone: 'neutral' }, { id: 'm4', title: 'Lunch', day: 0, start: '12:30', end: '13:30', tone: 'success' }, { id: 'm5', title: 'Focus: API', day: 0, start: '14:00', end: '16:00', tone: 'warning' },
// Tue (today). { id: 't1', title: 'Standup', day: 1, start: '09:00', end: '09:15', tone: 'alt' }, { id: 't2', title: 'Sprint planning', day: 1, start: '09:30', end: '11:00', tone: 'info', meta: 'Room 4' }, { id: 't3', title: 'Interview', day: 1, start: '11:15', end: '12:00', tone: 'warning' }, { id: 't4', title: 'Lunch', day: 1, start: '12:00', end: '13:00', tone: 'success' }, { id: 't5', title: 'Product sync', day: 1, start: '13:30', end: '14:30', tone: 'alt' }, { id: 't6', title: 'Design crit', day: 1, start: '14:00', end: '15:30', tone: 'warning' }, { id: 't7', title: 'Release prep', day: 1, start: '16:00', end: '17:30', tone: 'info' },
// Wed. { id: 'w1', title: 'Standup', day: 2, start: '09:00', end: '09:15', tone: 'alt' }, { id: 'w2', title: 'Deep work', day: 2, start: '10:00', end: '12:00', tone: 'info' }, { id: 'w3', title: 'Lunch with Emma', day: 2, start: '12:00', end: '13:00', tone: 'success', meta: 'Café Nero' }, { id: 'w4', title: 'Team meeting', day: 2, start: '14:00', end: '15:30', tone: 'alt', meta: 'Room 4' }, { id: 'w5', title: 'Review', day: 2, start: '14:30', end: '16:00', tone: 'warning' },
// Thu. { id: 'th1', title: 'Standup', day: 3, start: '09:00', end: '09:15', tone: 'alt' }, { id: 'th2', title: 'Roadmap', day: 3, start: '10:00', end: '11:30', tone: 'info' }, { id: 'th3', title: 'Lunch', day: 3, start: '12:00', end: '13:00', tone: 'success' }, { id: 'th4', title: 'Customer call', day: 3, start: '15:00', end: '16:00', tone: 'neutral', meta: 'Zoom' }, { id: 'th5', title: 'Release', day: 3, start: '16:00', end: '18:00', tone: 'success' },
// Fri. { id: 'f1', title: 'Standup', day: 4, start: '09:00', end: '09:15', tone: 'alt' }, { id: 'f2', title: 'Retro', day: 4, start: '10:00', end: '11:00', tone: 'alt' }, { id: 'f3', title: 'Lunch', day: 4, start: '12:00', end: '13:00', tone: 'success' }, { id: 'f4', title: 'Sprint demo', day: 4, start: '14:00', end: '15:00', tone: 'info' },];
// Hold the events in state so a drag can reschedule them. Dropping normally// moves the event; holding Ctrl (Cmd on macOS) while dragging drops a copy.const [items, setItems] = useState(events);
// Give it a bounded height so the 24-hour grid scrolls internally.<div className="h-[32rem] overflow-hidden rounded-xl border border-hairline"> <CalendarWeek className="h-full" days={days} events={items} startHour={0} endHour={24} initialScrollHour={8} timezone="UTC+7" now={{ day: 1, minutes: 14 * 60 + 40 }} onEventDrop={(event, target, { copy }) => { if (copy) { setItems((prev) => [ ...prev, { ...event, id: `${event.id}-copy-${Date.now()}`, day: target.day, start: target.start, end: target.end }, ]); } else { setItems((prev) => prev.map((e) => e.id === event.id ? { ...e, day: target.day, start: target.start, end: target.end } : e, ), ); } }} /></div>Props
| Prop | Type | Default | Notes |
|---|---|---|---|
days | CalendarDay[] | — | Week columns: weekday, date, today, count. |
events | CalendarEvent[] | — | Flat list; each event names its day index, start/end and tone. |
startHour | number | 7 | First visible hour (0–23). |
endHour | number | 21 | Grid end bound (1–24). |
hourHeight | number | 56 | Pixel height of one hour row. |
timezone | ReactNode | — | Corner label above the time gutter. |
now | { day; minutes } | — | Draws the current-time rule at this position. |
hour24 | boolean | false | Use 24-hour time labels. |
snapMinutes | number | 15 | Drag snap increment. |
newEventMinutes | number | 60 | Default length for an event added from a slot. |
renderEventPopover | (event, api) => ReactNode | — | Content for a popover anchored to a clicked event. |
renderSlotPopover | (slot, api) => ReactNode | — | Content for a popover anchored to a clicked empty slot. |
onEventDrop | (event, target, { copy }) => void | — | Enables drag-to-reschedule; copy is true when Ctrl (Cmd on macOS) is held. |
onEventClick | (event) => void | — | Lower-level click callback when no event popover is set. |
onSlotClick | (day, minutes) => void | — | Lower-level slot callback when no slot popover is set. |
CalendarEvent.tone | CalendarTone | 'info' | neutral · success · warning · danger · info · alt. |
CalendarEvent.allDay | boolean | — | Renders in the all-day band instead of the time grid. |