Build a Google Calendar–style view
Week grid: time-axis vertical (hours), day-columns horizontal. Events absolutely positioned within their day column: `top = startMinutes * pxPerMinute`, `height = duration * pxPerMinute`. Overlapping events use the cluster-and-column layout algorithm to share horizontal space. Multi-day events span the day columns above the grid. Click empty to create, drag to move/resize.
Implementation of the week-view (see also the system-design version: [[frontend-system-design-design-a-calendar-application-like-google-calendar]]).
1. The grid
+-------+--------+--------+--------+--------+
| Time | Mon | Tue | Wed | Thu |
+-------+--------+--------+--------+--------+
| 8 AM | | [Stand-up] | |
| 9 AM | [1:1] | | [Demo] | |
| 10 AM | | | | |- A vertical time axis (1 row per hour or 15 minutes).
- N day columns (5 or 7), each is a positioning container.
- Time slots are usually 60-minute rows with sub-grid lines.
2. Event positioning
For a day column of height columnHeight representing 24 hours (pxPerMinute = columnHeight / (24 * 60)):
const top = startMinutesFromMidnight * pxPerMinute;
const height = (endMinutes - startMinutes) * pxPerMinute;Render each event as an absolutely positioned div within its day column.
3. Overlap layout — the core algorithm
function layoutDay(events) {
// events: {start, end, ...}
events.sort((a, b) => a.start - b.start || b.end - a.end);
// Group into clusters of mutually-overlapping events.
const clusters = [];
let cur = null;
for (const e of events) {
if (!cur || e.start >= cur.endMax) {
cur = { events: [], endMax: e.end };
clusters.push(cur);
}
cur.events.push(e);
cur.endMax = Math.max(cur.endMax, e.end);
}
// Within a cluster, assign each event the leftmost free column.
for (const cluster of clusters) {
const cols = []; // each col is an array of events
for (const e of cluster.events) {
let placed = false;
for (const col of cols) {
if (col[col.length - 1].end <= e.start) { col.push(e); e.col = cols.indexOf(col); placed = true; break; }
}
if (!placed) { cols.push([e]); e.col = cols.length - 1; }
}
const numCols = cols.length;
for (const e of cluster.events) { e.numCols = numCols; }
}
return events;
}Render:
<EventBlock style={{
top, height,
left: `${(e.col / e.numCols) * 100}%`,
width: `${(1 / e.numCols) * 100}%`,
}} />4. Multi-day & all-day events
Render in a separate band above the time grid; spans the relevant day columns. Use a small layout pass that packs overlapping multi-day bars into stacked rows.
5. Interactions
- Click empty slot → open a "new event" modal pre-filled with that slot's time.
- Drag on empty → create with computed duration.
- Drag an event → move; show ghost; on drop, snap to 15-minute grid, optimistically update.
- Resize handle at bottom → change duration.
- Snap to 15-min increments; use
transform: translatefor the drag to avoid layout.
6. Current-time indicator
A horizontal line at now * pxPerMinute in today's column; update once per minute.
7. Timezone
Render times in the user's display timezone; events come in as ISO timestamps. Use date-fns-tz or Temporal.
8. Performance
- Only render the visible week's events.
- Memoize the layout result per day.
- Use
transformfor drag;requestAnimationFramefor mousemove. - Avoid re-laying-out every event on a single move; only the affected day.
9. Accessibility
- Grid with
role="grid",role="row",role="gridcell". - Events as
<button>with descriptive labels: "Stand-up, Tuesday 9 AM to 9:30 AM". - Keyboard: arrows to move focus across slots; Enter to open; Shift+arrows to move the focused event.
- Live region announces moves and new-event creation.
Interview framing
"Day columns + a vertical time axis. Events are absolutely positioned within their day column using top = startMinutes * pxPerMinute and height = duration * pxPerMinute. Overlapping events need the cluster-and-columns layout: sort by start, group overlapping into clusters, assign each event the leftmost free column, then render with left = col/numCols and width = 1/numCols as percentages. Multi-day events go in a band above the grid. Drag uses transform to avoid layout. Snap to 15 minutes. Render only the visible week; memoize the per-day layout. Accessibility is grid semantics + buttons for events + keyboard nav."
Follow-up questions
- •Walk through the overlap layout algorithm with 3 overlapping events.
- •Why use transform for drag instead of changing top/left?
- •How do you handle multi-day events?
- •How does timezone handling change the math?
Common mistakes
- •No overlap layout — events visually stacked.
- •Animating top/left on drag — layout thrash.
- •Re-layout on every mousemove frame.
- •Hardcoding pixels for time slots — breaks on responsive sizing.
Performance considerations
- •Render only visible week; memoize layout per day; transform-only drag; rAF mousemove; avoid full re-render on every drag tick.
Edge cases
- •Event crossing midnight.
- •DST days (23/25 hours).
- •Many overlapping events squeezing widths.
- •Very short events (5 minutes) — minimum visual height.
Real-world examples
- •Google Calendar week view, Cal.com, Fantastical.
- •react-big-calendar, FullCalendar.