Design a calendar UI like Google Calendar
Time-based grid (day/week/month). Events stored normalized with start/end + recurrence rule (RRULE). Render by computing visible occurrences for the current view. Handle overlap layout, drag-to-create/move, and timezones.
A calendar app is one of the densest "design a UI" prompts: dozens of features, but only 5 hard problems underneath. The senior answer focuses on those: data model with recurrence, view rendering, overlap layout, drag interactions, and timezones.
Clarifying questions (always).
- Day / Week / Month views, or all three?
- Recurring events?
- Multi-day events?
- Multi-user / shared calendars?
- Real-time collaboration (someone else moves an event)?
- Timezone-aware (events in user's TZ vs creator's TZ)?
- Offline?
Scope this answer to: Day/Week/Month, recurring + multi-day, single user, timezone-aware.
Data model.
type EventInput = {
id: string;
title: string;
start: string; // ISO with timezone, e.g., "2026-05-11T14:00:00-07:00"
end: string;
rrule?: string; // RFC 5545 RRULE: "FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T000000Z"
exceptions?: string[]; // dates the recurrence skips
calendarId: string;
};
type EventOccurrence = {
eventId: string; // points back to EventInput
start: Date;
end: Date;
isException?: boolean; // user moved/edited this single occurrence
};The split matters: the EventInput is the master record (one row per logical event); EventOccurrence is what's visible in the current view. For a weekly recurring event over 2 years, you store one input but render N occurrences.
Recurrence — use a library. Implementing RFC 5545 RRULE from scratch is a multi-month project. Use rrule.js to expand a recurrence rule into occurrences within a window. Store exceptions (EXDATE) and overrides separately.
Render = expand current view's window.
function visibleOccurrences(events: EventInput[], from: Date, to: Date): EventOccurrence[] {
const out: EventOccurrence[] = [];
for (const e of events) {
if (e.rrule) {
const rule = RRule.fromString(e.rrule);
for (const start of rule.between(from, to, true)) {
if (e.exceptions?.includes(start.toISOString())) continue;
const duration = +new Date(e.end) - +new Date(e.start);
out.push({ eventId: e.id, start, end: new Date(+start + duration) });
}
} else if (overlaps(new Date(e.start), new Date(e.end), from, to)) {
out.push({ eventId: e.id, start: new Date(e.start), end: new Date(e.end) });
}
}
return out;
}Memoize by view window so view switches don't recompute.
Overlap layout (the tricky one). Two events at 2pm–3pm both want the same slot. Algorithm:
- Sort occurrences by start time.
- Walk through; group events that overlap into a "cluster."
- Within a cluster, assign each event a column (greedy: pick the leftmost column not in use at this event's start).
- Render each event at
left: column / clusterCols * 100%, width100% / clusterCols.
For a clean look, also support partial-width overlaps (Google Calendar style — adjacent events get full width if they only overlap by 5 minutes).
Drag interactions.
- Drag to create: mousedown on an empty cell → drag to set duration → mouseup confirms. Track
startY, compute time from pixel offset (y / pxPerHour). - Drag to move: event card is draggable; drop snaps to 15-minute increments.
- Drag edge to resize: top/bottom of card resizes start/end.
Use pointer events (pointerdown/pointermove/pointerup) for cross-device support and capture so the drag continues even if pointer leaves the cell.
Timezones — the senior pitfall. Store events in UTC (or with offset) on the server. Display in the user's TZ. Use luxon or date-fns-tz — never raw new Date(), which interprets in the local TZ. Recurring events are tricky: "every Monday at 9am" in London DST-shifts twice a year; respect the TZ on the rule, not the absolute UTC.
Editing recurring events. UX prompt: "Edit this event only / This and following / All events." Stored as: single override (push into exceptions, create a one-off event), split the recurrence into two rules, or modify the master.
Real-time / multi-user. WebSocket subscription per calendar; events arrive as patches. Use the same seq + resume pattern as other real-time features. Conflict resolution: last-write-wins on event content; for simultaneous moves, server is the arbiter.
Performance.
- Window the visible range — don't expand all recurrences ever, only for the current view.
- Memoize per view; switching weeks should hit cache for re-visits.
- Virtualize the month view's day cells if rendering > 6 weeks at once.
- Avoid re-rendering all events on a single drag — only the dragged event and its overlap cluster need to update.
Accessibility.
- Keyboard nav: arrows move focus across cells, Enter creates event, Tab into an event opens the editor.
- ARIA:
role="grid",gridcell,aria-labeldescribing date and events. - Don't rely on color alone for calendar distinction — patterns or labels.
Tests.
- Recurrence expansion across DST boundaries.
- Overlap layout with 1, 2, 5, 10 concurrent events.
- Edit-this-and-following splitting the rule correctly.
- Timezone display when user's TZ differs from event TZ.
Code
Follow-up questions
- •How would you store and expand recurring events?
- •Why do recurring events need the user's timezone, not just UTC?
- •How would you handle real-time updates from another collaborator?
- •What's the algorithm for laying out overlapping events?
Common mistakes
- •Storing recurrence as exploded rows — millions of rows for forever-rules.
- •Using new Date() everywhere — TZ bugs on DST.
- •Re-expanding all events on every render instead of memoizing per view.
- •Storing layout positions in state instead of computing from clusters.
Performance considerations
- •Window expansion to current view; cache by [calendarId, viewKey].
- •Use CSS Grid for the day/week grid; absolute-position events on top.
- •Memoize cluster layouts; only recompute when events in that cluster change.
Edge cases
- •Event spanning midnight into the next day — render as two segments per day.
- •Recurring event ending mid-occurrence-window — clip last instance.
- •DST 'spring forward' on an event at 2:30am — RRULE library handles, but verify.
Real-world examples
- •Google Calendar, Notion Calendar (Cron), Fantastical, Outlook Calendar — all use RRULE + cluster overlap layout.