Design a calendar week view
A reusable day, 3-day and week time grid that product teams embed, like FullCalendar's time grid or the week view of Google Calendar. Drawing boxes on a grid is easy; the hard parts are an API built on instants with async edit callbacks, mapping time to pixels per day column in a chosen time zone (including 23 and 25 hour days), splitting events at midnight, packing all-day bars, drag to create, move and resize that stays smooth and rolls back on failure, keyboard and screen reader access, localization, and server rendering without knowing the user's time zone.
The question
Our design system needs a calendar week view. Three product teams will embed it: the meetings app, a room booking tool and a shift planner for shops. It shows days side by side with hours running down, and users drag on it to create, move and resize events.
Design the component, not the calendar app. Walk me through its API, how you place events in time, what happens on daylight saving days, how dragging works on mouse, touch and keyboard, and how it stays fast and accessible.
Real-life examples: The week view of Google Calendar, Outlook on the web, day and work week views, FullCalendar's timeGrid views, react-big-calendar's week and day views, Room and shift booking screens in office tools
Requirements: a component, not an app
A week view is a grid. Days run across as columns, time runs down as rows, and each event is a box whose top edge is its start and bottom edge its end. A day view is the same grid with one column; a work week hides the weekend; a 3-day view is what phones show. We call the whole family a time grid.
This question is about the component. The Google Calendar guide designs the whole app: recurring events, sync, sharing, and the overlap layout algorithm with a lab; read it for those, because this guide only links to them. Here the calendar data belongs to whoever embeds us. Our job is the piece every calendar product needs: a grid that turns instants into pixels honestly, handles every gesture, and hands edits back to the host app.
Questions to ask first
| Question | Answer we assume | What it changes |
|---|---|---|
| Who embeds it? | Three teams: meetings, room booking, shift planning | A small, typed API with render slots. No team can edit our internals, so every need becomes a prop or a callback. |
| Who owns the data? | The host app fetches and saves events | We get events as props and report edits through callbacks. We never call a server. |
| How many events in view? | Usually 20 to 60 a week; a room booking screen with 30 rooms overlaid can show 300 | Layout must be cheap at 300 events, and drag must not redo it 120 times a second. |
| Which views? | Day, 3-day, work week, week; the host controls which one | The number of columns is a prop. Phones get the 3-day view. |
| Which time zone? | One display zone per grid, chosen by the host, default the user's | All math runs in that zone, not the device's. Daylight saving days must look right. |
| Which inputs? | Mouse, touch, pen, keyboard, screen readers | Pointer events with capture, long press on touch, and a full keyboard model for every drag. |
| Which locales? | Global, including right-to-left languages | Week start, 12 or 24 hour clock, day names and column order all come from the locale. |
| Server rendering? | The meetings app renders on the server with Next.js | The server doesn't know the user's time zone. We need a plan for the first paint. |
Functional requirements
- Show a day, 3-day, work week or week grid for a range the host controls, with a time gutter on one side and day headers on top.
- Place timed events by their start and end instants in the grid's time zone, side by side when they overlap.
- Show all-day and multi-day events as bars in a separate row above the grid.
- Drag on empty space to create an event, drag an event to move it, including to another day, and drag its top or bottom edge to resize it.
- Snap every drag to a step, 15 minutes by default, and let the user cancel any drag with Escape.
- Report every edit to the host through a callback that returns a promise; show the change at once and undo it if the promise rejects.
- Draw a current-time line and scroll to a sensible hour on first show.
- Let teams replace how an event and a time slot look, without forking the component.
- Do all of the above with a keyboard, and announce it to screen readers.
Non-functional requirements
- Fast first render: placing 300 events in a week should take under 20 ms of script on a mid-range laptop, so switching weeks feels instant.
- Smooth drag: 60 frames per second on a mid-range Android phone, which leaves about 16 ms per frame for everything, so our pointer handler should stay under 2 ms.
- Responsive input: every click, key press and drop paints its result within 200 ms, the good threshold for Interaction to Next Paint (INP).
- Correct time: no event drawn at the wrong hour on any day of the year in any IANA time zone, including the days clocks change.
- Accessible: WCAG 2.2 AA. Every action that needs a drag also works from the keyboard (success criterion 2.1.1) and with single clicks or taps without dragging (2.5.7, Dragging Movements).
- Small: about 25 KB gzipped without a date library, because the browser's
IntlAPI already knows every time zone.
Out of scope, and why
- Recurrence rules, sync, sharing and invitations: app concerns, covered in the Google Calendar guide. The host expands recurring events into instances before passing them in.
- Month view and resource timelines: different grids. We keep the core reusable so they can be added later, and touch on horizontal virtualization for timelines.
- Event editing forms: the host opens its own dialog from our callbacks.
Check your understanding · Exercise 1
Which answers change the component's design the most (select all that apply)? Select all that apply.
What makes a time grid hard
A first version takes an afternoon: seven div columns, 24 rows of 48 pixels, and each event placed with top = startHour * 48. It looks done. Then real data arrives, and five properties of the problem break it one after another. Every later chapter answers one of them.
Five properties that drive the design
- Time is not a straight line on screen. An event is two instants: points on the world's timeline. The grid shows wall-clock hours in one zone. On most days one hour is 48 pixels, but twice a year a day has 23 or 25 hours, and an event can start on one day and end on the next. The mapping from instant to pixel is per column and sometimes bent.
- The component doesn't own the truth. The host app owns events and the server decides whether a move is allowed. A room may already be booked. So every edit is a guess that can be refused, and the grid must show the guess at once and undo it cleanly.
- Gestures are continuous and fast. A pointer reports its position 60 to 240 times a second. If each report re-runs layout for 300 events and re-renders them, the drag stutters. The hot path during a drag must touch one element, not the whole week.
- Space is shared. Overlapping meetings share a column's width, multi-day events share the top row, and on a phone the same finger that drags an event must also scroll the page.
- Many people, many settings. A Monday-first week in Berlin, a Sunday-first week in Chicago, a Saturday-first week in Cairo, 12 or 24 hour clocks, right-to-left columns in Hebrew and Arabic, keyboard and screen reader users. None of these can be bolted on later without touching the coordinate math.
Notice what is missing: fetching, caching, sync. Those are the host's job. A component interview rewards depth on the five points above, so we will spend almost all our time on them.
Think first
The naive code computes top = (start.getHours() * 60 + start.getMinutes()) * 0.8. What are two different ways this puts a meeting at the wrong height?
First, getHours() uses the device's time zone, not the grid's. A user in London viewing a New York team calendar sees every event shifted by five hours, unless the grid zone happens to equal the device zone.
Second, even in the right zone it ignores the day the event belongs to. A meeting from 22:00 to 02:00 gets one box starting at 22:00 with a height that runs off the bottom of the column, and nothing appears on the next morning. On clock change days the gap and repeated hour also break the simple formula.
The component API: instants in, promises out
Three teams will build on this API for years, so we design it before any pixels. The lesson on component API design covers the general rules (controlled versus uncontrolled, slots, events). Here we apply them to the three questions only a calendar has: what an event looks like, who controls the visible range, and how edits come back.
What an event is
A timed meeting happens at an instant: the same moment for everyone on Earth, stored as an ISO string with an offset (2026-03-09T15:00:00Z) or epoch milliseconds. An all-day event is different: a holiday on 9 March is 9 March in every zone, so it is a pair of plain dates with no time and no zone. The date picker guide explains this split between instants and dates in depth; we only respect it. Mixing them is the classic bug where a birthday stored as midnight UTC shows on the 8th for users west of Greenwich.
type TimedEvent = {
id: string;
title: string;
start: string; // instant: "2026-03-09T15:00:00Z"
end: string; // exclusive
allDay?: false;
timeZone?: string; // where it was created, shown as a hint only
editable?: boolean; // false: no drag handles, no keyboard move
};
type AllDayEvent = {
id: string;
title: string;
allDay: true;
startDate: string; // plain date: "2026-03-09"
endDate: string; // exclusive: "2026-03-11" means 9th and 10th
};
type CalendarEvent = (TimedEvent | AllDayEvent) & { color?: string; meta?: unknown };
The timeZone field is not used for placement. Placement only needs the instant and the grid's zone. The field lets renderEvent show "9:00 AM in Tokyo" next to the local time, which helps travellers.
Who controls what is visible
The host must load events for the visible days, put the range in the URL, and sync the view with its own toolbar. So the range is controlled: the host passes view and date (any plain date inside the range) and we call onRangeChange when the user swipes, presses the next-range key or clicks a day header.
From view, date, the first day of the week and the zone, a pure function derives the list of visible days. We also report the range as instants (rangeStart, rangeEnd) so the host can query GET /events?from=...&to=... without doing zone math itself.
type WeekViewProps = {
events: readonly CalendarEvent[];
view: "day" | "3day" | "workweek" | "week";
date: string; // plain date inside the range
onRangeChange(next: { view: View; date: string;
rangeStart: string; rangeEnd: string }): void;
timeZone?: string; // IANA name, default: the user's
locale?: string; // BCP 47 tag; pass it so server and client agree
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6; // default from locale
minTime?: string; maxTime?: string; // "07:00", "21:00"
slotMinutes?: 15 | 30 | 60; // grid lines, default 30
snapMinutes?: 5 | 10 | 15 | 30; // drag step, default 15
scrollTo?: "now" | string; // "08:00" for working hours
renderEvent?(seg: EventSegment, state: DragState): ReactNode;
renderSlot?(slot: { start: string; end: string }): ReactNode;
onCreate?(draft: { start: string; end: string; allDay: boolean }): Promise<void>;
onMove?(change: EventChange): Promise<void>;
onResize?(change: EventChange): Promise<void>;
onEventOpen?(id: string): void; // click or Enter on an event
};
minTime and maxTime crop the grid for a shop that opens at 7 and closes at 21, which saves 10 hours of scrolling. slotMinutes sets the grid lines and snapMinutes the drag step; keeping them apart lets a room booking tool draw hour lines but snap to 5 minutes. FullCalendar makes the same split with slotDuration (default 30 minutes) and snapDuration (default: the slot size). renderEvent gets a segment, not an event, because one event can be drawn as several pieces (chapter 5).
Decision How should an edit (move, resize, create) get back to the host?
What we pick: Promise-returning callbacks with a pending layer. It is how FullCalendar's eventDrop with revert() works in spirit, but a promise is easier to compose with fetch and with data libraries such as TanStack Query. The optimistic updates lesson covers the general pattern; chapter 8 shows our version.
Check your understanding · Exercise 2
A shift planner team asks for these. Which belong in our API (select all that apply)? Select all that apply.
Time to pixels: one map per day column
Everything on the grid, events, the now line, the drop target under the pointer, comes from one pair of functions: yOf(instant) and instantAt(y). Get them right once and every feature is right. Get them wrong and bugs appear on two days a year, in some zones, in bug reports nobody can reproduce.
We use 48 pixels per hour, so 0.8 pixels per minute and 12 pixels per 15 minute row. A full day is 1,152 pixels tall. Each day column covers the instants from the start of its date in the grid's zone to the start of the next date. We say "start of the date", not midnight, because a few zones move their clocks at midnight (Cuba still does; Brazil did until 2019), so on those days 00:00 never happens and the day begins at 01:00.
Reading the wall clock in any zone
We never call getHours(), which answers in the device's zone. We ask Intl.DateTimeFormat with the grid's timeZone for the wall-clock hour and minute of an instant, using hourCycle: "h23" so midnight reads as 0, and formatToParts so we get numbers, not a string to parse. Creating a formatter is slow (it loads locale and zone data), so we create one per zone and reuse it. Temporal's ZonedDateTime does the same job with a nicer API; the date picker guide covers when to use it or its polyfill.
The two days a year that break the simple formula
In New York on Sunday 8 March 2026 clocks jump from 02:00 to 03:00: the day has 23 hours and 02:30 never happens. On Sunday 1 November 2026 they fall back from 02:00 to 01:00: the day has 25 hours and 01:30 happens twice, first in daylight time (EDT, UTC minus 4) and then in standard time (EST, UTC minus 5). Berlin changes on 29 March 2026 (02:00 jumps to 03:00) and 25 October 2026 (03:00 falls back to 02:00). Lord Howe Island in Australia shifts by only 30 minutes.
The Google Calendar guide draws these days with plain wall-clock rows and patches two edge cases, which suits an app whose nights are mostly empty. A component that a shift planner embeds can't assume that: night shifts cross the change every year. The task says to draw these days honestly, so here are three ways to try.
Decision How do we map an instant to a y position in a day column?
What we pick: The step map. It keeps the shared gutter honest for 23 of 24 rows and makes the odd row visibly odd. A fourth option, adding a full extra 1 AM row to the whole week on fall back day, keeps heights true but changes the gutter for one week a year and leaves six columns with a dead row; we mention it and move on.
const STEP = 15 * 60_000;
type Step = { t: number; y: number; h: number }; // instant, top, height
function buildColumn(dayStart: number, dayEnd: number, px: number): Step[] {
const steps: Step[] = [];
for (let t = dayStart; t < dayEnd; t += STEP) {
steps.push({ t, y: rowOf(t) * px, h: px }); // rowOf: wall minutes / 15
}
for (let i = 1; i < steps.length; i++) {
// Compare wall rows, not y: earlier passes of this loop rewrite y.
const n = rowOf(steps[i - 1].t) + 1 - rowOf(steps[i].t); // rows repeated
if (n <= 0) continue; // 0: normal step; < 0: a gap, left empty
const top = steps[i].y; // first pass in top halves, second below
for (let k = 0; k < n; k++) {
steps[i - n + k] = { ...steps[i - n + k], y: top + (k * px) / 2, h: px / 2 };
steps[i + k] = { ...steps[i + k], y: top + ((n + k) * px) / 2, h: px / 2 };
}
i += n - 1; // skip the second pass we just placed
}
return steps;
}
function yOf(steps: Step[], t: number): number {
const s = steps[lastAtOrBefore(steps, t)]; // binary search on t
return s.y + ((t - s.t) / STEP) * s.h;
}
instantAt(y) is the reverse: find the step whose band contains y and interpolate. If y falls in a gap, it returns the first instant after the gap (03:00), so a drop there lands on the next real time. minTime and maxTime just subtract the y of the first visible row and clip.
Cost: a column has 92 to 100 steps, each one formatToParts call. Most days need none of that. If the UTC offset at the day's start equals the offset at its end and the day is exactly 24 hours long, the map is a straight line, y = (t - dayStart) / 60000 * 0.8, and we skip the walk. Only transition days build steps, and we cache maps by zone and date, so switching weeks back and forth costs nothing.
Check your understanding · Exercise 3
New York, Sunday 1 November 2026. Which statements about our step map are true (select all that apply)? Select all that apply.
Cutting events into segments, and packing the all-day row
Before anything is drawn, a pure function turns events into segments: the pieces that actually appear in one column or one row. Segments are what renderEvent receives, what the overlap layout arranges, and what the keyboard walks through. Keeping this step pure (same input, same output, no DOM) lets us memoize it and unit test it with fixed zones and dates.
Events that cross midnight
A night shift runs from 22:00 Monday to 06:00 Tuesday. Clip the event's [start, end) to each column's [dayStart, dayEnd) and you get two segments: Monday 22:00 to the end of Monday, and Tuesday from its start to 06:00. Each segment remembers startsBefore and endsAfter, so renderEvent can square off the cut edge and only the first piece shows the title in full. Both pieces share the event id: hover one and both highlight, drag one and the whole event moves.
[start, end) to each column's [dayStart, dayEnd). The flags tell renderEvent which edge is a cut, so it can square it off and show the title once.Where does a three-day conference with set times go? Cutting it into three full-height columns hides every other meeting. Many calendars move timed events of 24 hours or more into the all-day row, and we do the same by default: under 24 hours become segments, 24 hours or more become bars. A prop can change the threshold for a shift planner whose 26 hour shifts must stay in the grid.
Overlapping events, briefly
Within a column, segments that overlap in time sit side by side. The Google Calendar guide derives that layout step by step, with a lab: clusters of overlapping events, a greedy sub-column for each, and a stretch into empty columns to the right. We reuse it unchanged on segments. It costs O(n log n), so 300 events take well under a millisecond.
One detail matters for a component: overlap must use the drawn height, not the real one. A 5 minute check-in is 4 pixels tall, too short to click, so we draw at least 20 pixels (25 minutes at our scale). If two 5 minute events start 10 minutes apart, their real times don't overlap but their boxes do. Running the layout on drawn extents puts them side by side instead of on top of each other.
Packing the all-day row into lanes
All-day and long events are horizontal bars above the grid. Each bar covers a range of visible day indices, clipped to the week. We place bars in lanes (rows of bars) with a greedy rule: sort by first day, longer bars first, then give each bar the lowest lane that is free on every day it covers. Longer first keeps the long bars on top and steady from week to week, which users notice.
| Bar (sorted) | Days | Lanes busy on those days | Lane given |
|---|---|---|---|
| On call | Mon to Sun | none | 0 |
| Conference | Mon to Wed | 0 | 1 |
| Leave | Tue | 0, 1 | 2 |
| Sprint review | Wed to Fri | 0, 1 on Wed | 2 |
| Offsite | Thu to Fri | 0, 2 | 1 |
The row must not eat the grid. We show at most 3 lanes (24 pixels each) and replace the rest with a "+2 more" button per day that opens a popover listing them. A toggle can expand the row. The all-day row sits outside the vertical scroller, so it stays visible while the hours scroll under it, and it shares the day columns' widths so bars line up with the grid.
Check your understanding · Exercise 4
Which statements about segments are true (select all that apply)? Select all that apply.
Rendering: boxes, the now line, and a fast drag path
We know where every segment goes. Now we choose how the browser draws it and how much we draw. Then come two small features that are easy to get subtly wrong, the current-time line and the hour the grid opens at, and finally how we keep a drag at 60 frames per second when the page holds 300 events.
Decision How do we put event boxes on the grid?
What we pick: Absolute boxes inside day columns. Canvas only pays off at thousands of boxes, which a week never has; a resource timeline with 200 rooms might, and that is a different component. Using logical properties (inset-inline-start) means right-to-left layouts flip with no extra code.
How much to render
A day is 1,152 pixels tall, about two screens. Virtualizing hours (rendering only the visible rows) saves almost nothing: the grid lines are a background, slot cells are 336 empty elements for a week at 30 minutes, and events are at most a few hundred. The virtualized list guide's techniques are not needed vertically.
Horizontally is different. On a phone the user swipes between 3-day pages and expects the next page to be there as the finger moves, not after it lifts. So we render the current range plus one range on each side in a horizontal scroller with scroll-snap-type: x mandatory, and when a swipe settles we shift the window and call onRangeChange.
Neighbors get content-visibility: auto, so the browser skips their layout and paint until they scroll into view, plus contain-intrinsic-size so a skipped page keeps its width. A scrolling timeline that shows months of days side by side is where horizontal virtualization pays off: render only the day columns in view plus a buffer, exactly as a virtualized list does for rows.
content-visibility: auto keeps the neighbors cheap. A long timeline virtualizes columns the way a list virtualizes rows.A line that moves once a minute
At 0.8 pixels per minute the line moves less than one pixel per minute. Animating it at 60 frames per second would wake the main thread 3,600 times to move it once. setInterval(tick, 60000) is better but out of phase: started at 10:14:58, it updates at 10:15:58, so the line lags the device clock by up to a minute.
We align the timer to the minute: wait 60000 - (Date.now() % 60000) ms, update, then schedule the next one the same way. Each update recomputes the position with yOf(now) from the column's step map, so the line is right on DST days too, and moves the line with a transform.
function useMinuteClock(): number | null {
const [now, setNow] = useState<number | null>(null); // no time during SSR
useEffect(() => {
let timer = 0;
const schedule = () => {
setNow(Date.now());
timer = window.setTimeout(schedule, 60_000 - (Date.now() % 60_000) + 50);
};
const onVisibility = () => {
clearTimeout(timer);
if (document.visibilityState === "visible") schedule();
};
schedule();
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return now;
}
The clock starts as null, so the server and the hydrating client render the same HTML with no line, and the first effect fills it in. The extra 50 ms makes sure we wake just after the minute ticks over, not just before. When the tab is hidden we stop the timer; browsers throttle timers in background tabs anyway, so a hidden timer would fire late and pointlessly.
When the tab comes back we update at once, so a laptop opened after lunch shows the right time immediately. The same clock moves the "today" highlight at midnight, and a week view whose range no longer contains today hides the line. One clock serves the whole component, and only the line and today's header subscribe to it; if the whole grid read now, every minute would re-render 300 events.
visibilitychange it updates at once, then lines up with the minute again.Opening at the right hour
A grid that opens at midnight shows 8 hours of empty night. The scrollTo prop decides: "now" puts the current time about a third of the way down the viewport when today is visible, and otherwise scrolls to a time like "08:00". On the client we set scrollTop in a layout effect, after the DOM exists but before the browser paints, so the user never sees the jump from midnight.
- When the user moves to the next week, keep the current scroll position. Jumping back to 8:00 on every swipe is disorienting. FullCalendar's
scrollTime(default 06:00) does jump back on each range change unlessscrollTimeResetis turned off, so this is a real choice. - When the view changes from week to day, keep the same time at the top of the viewport.
- On a server render the browser paints the HTML before React hydrates, so a layout effect can't hide the jump. For a fixed hour like
"08:00", a tiny inline script right after the scroller setsscrollTopas soon as the element is parsed, usually before the first paint."now"needs the zone, so it waits for hydration unless the host passes a known zone.
Check your understanding · Exercise 5
Which statements about the now line are true (select all that apply)? Select all that apply.
The drag hot path
The naive drag puts the pointer position in React state on every pointermove. Each move re-renders the column, re-runs the overlap layout, and re-renders every event. On a busy week that can be 5 to 15 ms of work, repeated 60 to 120 times a second on a phone that has 16 ms per frame for everything, so the drag stutters and INP suffers. The runtime performance lesson explains why work per frame matters; here is our split.
pointermoveonly stores the latest coordinates and asks for onerequestAnimationFrameif none is pending. Many moves in one frame cost one update.- Inside the frame we call
instantAt(y), snap tosnapMinutes, and compare with the last snapped value. If nothing changed, we stop: a drag inside one 15 minute step costs nothing more. - When the snapped time changes we move one ghost element with
transform: translate(...)and set its height. Transforms skip layout, so only that layer is repainted. - The original box dims in place. Other events do not make room while the pointer moves; that would re-run layout 60 times a second and make boxes jump under the user's finger.
- On drop we write the change into the pending layer. The two affected columns (old day and new day) re-run segmenting and overlap layout once. Columns are memoized by their segment list, so the other five don't re-render.
Check your understanding · Exercise 6
A team reports that dragging stutters on a week with 250 events. Which changes help (select all that apply)? Select all that apply.
Drag to create, move and resize
Three gestures share one controller: press on empty space and drag to create, press on an event's body to move, and press on its top or bottom edge to resize. The mechanics match the kanban board guide (pointer events, capture, a movement threshold, long press on touch, a ghost moved with transforms), so we focus on what a time grid adds: snapping, two axes, edges that scroll, and gaps in time.
One set of listeners for mouse, pen and touch
We listen for pointerdown on the grid, not on each event, and use the target to decide which gesture it is. Then setPointerCapture(pointerId) sends every later move and the final pointerup to the grid, even when the pointer leaves the column or the window. A drag starts only after 5 pixels of movement, so a plain click still opens the event. Column rectangles are measured once at the start of the drag; vertical position is converted with the scroller's current scrollTop at each frame, so auto-scroll can't desync it.
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
const hit = hitTest(e.target); // create | move | resize-start | resize-end
if (!hit || !canEdit(hit)) return;
grid.setPointerCapture(e.pointerId);
drag = { hit, phase: "pressed", origin: [e.clientX, e.clientY],
cols: measureColumns(), grabOffsetMin: offsetInEvent(hit, e) };
if (e.pointerType === "touch") {
drag.timer = setTimeout(lift, hit.kind === "create" ? 500 : 250);
}
}
function onPointerMove(e: PointerEvent) {
if (!drag) return;
drag.last = [e.clientX, e.clientY];
if (drag.phase === "pressed") {
if (distance(drag.origin, drag.last) < 5) return;
if (e.pointerType === "touch") return reset(); // moved first: a scroll
lift(); // mouse and pen lift now
}
drag.frame ??= requestAnimationFrame(update);
}
Touch: let the page scroll until a long press
On a phone the whole grid is a scroll area, so a finger on it usually means "scroll". We set touch-action: manipulation, which keeps panning and pinch zoom and only turns off double-tap zoom, as dnd-kit recommends for touch dragging. A drag starts only after the finger rests: 250 ms with less than 5 pixels of movement on an event (the kanban guide's numbers), and 500 ms on empty space, where people most often put a finger to scroll. For comparison, react-big-calendar's longPressThreshold defaults to 250 ms and FullCalendar's longPressDelay to a full second.
When the timer fires we lift, call navigator.vibrate(10) where supported, and from then on call preventDefault() in a non-passive touchmove listener, which is what actually stops the page from scrolling. Changing touch-action after the touch began has no effect. We also cancel the contextmenu event during a lift so the long press menu does not pop up.
Snapping and what each gesture keeps
- Snap in wall-clock time. Convert y to an instant, read its wall-clock minutes, round to
snapMinutes, convert back. If the rounded time falls in a DST gap, push it to the first real time after the gap. - Move keeps the grab point and the real duration. If you grab a meeting 20 minutes below its top, the top stays 20 minutes above the pointer. The end is
newStart + duration, so a one hour meeting dragged across the spring forward night is still one real hour. - Move across days reads the column under the pointer from the measured rectangles; in right-to-left layouts the first column is on the right, which the rectangles already reflect.
- Resize changes only one edge, never past the other one: the minimum length is one snap step. A bottom edge stops at the end of the visible day; longer events are edited in the host's form.
- Create spans from the pressed slot to the current slot, in either direction. A click without a drag creates a default 30 minute event, and a click on an event opens the host's form (
onEventOpen), where its times can be typed: these are the single-pointer paths that WCAG 2.5.7 asks for.
Auto-scroll, crossing weeks, and Escape
When the pointer is within 40 pixels of the scroller's top or bottom edge, a frame loop scrolls by up to 15 pixels per frame, faster the closer it gets, and re-runs update() because the content moved under a still pointer. Holding near the left or right edge for 600 ms moves the range by one page and calls onRangeChange, so an event can be dragged into next week. Escape, pointercancel and lostpointercapture all cancel: the ghost disappears, the original box returns, no callback fires.
Drag on empty space to create, drag a box to move it, drag its top or bottom edge to resize. Escape cancels.
Check your understanding · Exercise 7
On a phone, a user puts a finger on an event and immediately swipes up. What should happen (select all that apply)? Select all that apply.
Saving edits: show it now, undo it on failure
A drop is a request, not a fact. The room may be taken, the user may have lost edit rights a minute ago, or the network may be down. Chapter 3 chose the contract: onMove, onResize and onCreate return promises, and the component keeps a pending layer of overrides on top of the host's events. Here is how that layer behaves.
type Pending = { start: string; end: string; v: number; status: "saving" | "saved" };
let version = 0;
async function commit(id: string, next: Times, save: () => Promise<void>) {
const v = ++version;
setPending((m) => new Map(m).set(id, { ...next, v, status: "saving" }));
try {
await save(); // contract: the host's data holds "next"
setPending((m) => (m.get(id)?.v === v ? new Map(m).set(id, { ...next, v, status: "saved" }) : m));
} catch (err) {
setPending((m) => (m.get(id)?.v === v ? without(m, id) : m));
if (latest(id) === v) announce(failMessage(id, err));
}
}
// Saved overrides go once the host's next events arrive.
useEffect(() => setPending((m) => withoutSaved(m)), [events]);
// What we draw: the host's events with pending overrides on top.
const shown = useMemo(
() => events.map((e) => {
const p = pending.get(e.id);
return p ? { ...e, start: p.start, end: p.end, saving: p.status === "saving" } : e;
}),
[events, pending],
);
On success the override is marked saved and stays until the host's next events prop arrives; then it goes and nothing moves. The contract asks the host to update its data before resolving: with TanStack Query, an onMutate or onSuccess cache write, both of which run before mutateAsync resolves. But the re-render can come a tick later, because TanStack Query batches its notifications with setTimeout(0) by default, so dropping the override at once would flash the old time for a frame.
On failure the override is removed at once and the event slides back to the time the host still has. The slide is a 150 ms transform transition, skipped when prefers-reduced-motion: reduce is set.
Two quick moves of one event
A user drops a meeting at 10:00 (version 1), changes their mind and drops it at 11:00 (version 2) before the first save returns. If version 1 then fails, rolling back would throw the meeting to its original 9:00 while the 11:00 save is still in flight. The version check prevents it: a settling promise only touches the override if it is still the newest.
Ordering on the server (the 10:00 request arriving after the 11:00 one) is the host's problem. The usual fix is to send an If-Match header with the event's version, so the server refuses a stale write with 412. The optimistic updates lesson covers both sides.
Creating has no id yet
A new event has no server id. On drop we draw a draft with a local id like draft-7, marked as saving, and call onCreate(draft). Many hosts open a title dialog at this point; the promise simply stays pending until the user saves or cancels. When it resolves, the host's events gain the real event with its server id, and the draft goes as they arrive; when it rejects (including a cancelled dialog), the draft disappears.
Telling the user what went wrong
- The host rejects with an error that may carry a
reason, such as "Room 4 is booked from 10:00 to 11:00". We pass it to anonErrorcallback so the host can show its own toast. - We also write one sentence to a polite live region: "Couldn't move Design review. It is back on Tuesday at 9:00." Screen reader users would otherwise miss the slide back.
- While saving,
renderEventreceivessaving: true, so teams can show a subtle stripe. We don't block further drags on that event; the version rule handles them.
Check your understanding · Exercise 8
Version 1 moves an event to 10:00; version 2 moves it to 11:00. Version 1's promise rejects while version 2 is still saving. What should the grid do (select all that apply)? Select all that apply.
Keyboard and screen readers
A time grid is one of the hardest widgets to make accessible, because its meaning is spatial: "this box is next to that box at 10 o'clock on Tuesday". A screen reader user can't see any of that, and a keyboard user can't drag. Both must still be able to read the week, create, move and resize. The keyboard navigation and ARIA patterns lessons cover roving tabindex and the APG grid pattern; here we decide how a calendar uses them.
Decision How does a keyboard user move around the grid?
What we pick: Two composite widgets plus the list view. Reading events is the common task, so it gets the simple model. Creating at an exact slot is rarer and gets the grid. The list view is not a fallback for us to forget; it renders from the same segments and is often what screen reader users prefer.
The keys
| Where | Key | Does |
|---|---|---|
| Slot grid | Arrows | Up and Down one slot, Left and Right one day (flipped in right-to-left layouts) |
| Slot grid | Page Up, Page Down | One hour up or down |
| Slot grid | Home, End | First and last day of the range |
| Slot grid | Enter | Create a default event at the slot (calls onCreate) |
| Event layer | Arrows | Previous or next event in time; nearest event on the next or previous day |
| Event layer | Enter | Open the event (onEventOpen) |
| Event layer | Space | Pick up the event to move it, as on the kanban board |
| Moving | Up, Down | Move by one snap step (15 minutes) |
| Moving | Left, Right | Move to the previous or next day at the same time |
| Moving | Shift + Up, Shift + Down | Resize: move the end by one snap step |
| Moving | Space or Enter | Drop and save (calls onMove or onResize) |
| Moving | Escape | Cancel and put it back |
| Anywhere | Alt + Page Down, Alt + Page Up | Next and previous range (calls onRangeChange) |
A screen reader passes arrow keys to the page only in focus mode, and most switch to that mode when focus enters a widget with a composite role such as grid; on a plain div of buttons they keep the arrows for reading. The ARIA patterns lesson explains both modes; for us it means both layers need a role. The slot grid is an APG grid. The event layer can be a layout grid too: one column per day, that day's events down it in time order, so moving sideways announces the new day.
What a screen reader hears
Each event is a button whose accessible name says what sighted users see at a glance: "Design review, Tuesday 10 March, 9:00 to 10:00 AM, Room 4". We build it with Intl.DateTimeFormat in the grid's zone and locale; formatRange produces "9:00 to 10:00 AM" style ranges in each language's own pattern. A segment adds "continues from Monday" or "continues to Wednesday", and overlaps add "1 of 3 at this time", so the user knows to arrow further. The slot grid's cells are named "Tuesday 10 March, 9:30 AM".
Moves happen without focus changing to anything new, so we narrate them in one polite live region (aria-live="polite") shared by the component. Each message is short, because a fast run of arrow presses would otherwise queue long sentences.
Picking up: "Picked up Design review. Arrows to move, Shift and arrows to change the end, Space to drop, Escape to cancel." Each step: "Tuesday 9:15 to 10:15 AM." Dropping: "Moved to Tuesday 9:15. Saving." Then either nothing more, or "Couldn't move Design review. It is back on Tuesday at 9:00."
Keeping focus through moves
A moved event may re-render in another column, which means a new DOM element and lost focus. We remember the focused event id and, after each commit or rollback, focus the element with that id again. After onCreate, the host usually opens a dialog; when it closes we focus the new event, or the slot that started it if creation was cancelled.
Check your understanding · Exercise 9
Which choices help screen reader users the most (select all that apply)? Select all that apply.
Locales, right-to-left, and the server that doesn't know your time zone
Everything a locale changes
The locale formatting lesson covers Intl in general. A calendar uses five parts of it, and each one shows up in the layout, not just in text.
| What | Example | Where it comes from |
|---|---|---|
| First day of the week | Monday in Germany, Sunday in the US and Israel, Saturday in Egypt | new Intl.Locale(locale).getWeekInfo().firstDay (1 is Monday, 7 is Sunday); a firstDayOfWeek prop overrides it |
| Weekend, for the work week view | Saturday and Sunday in most places, Friday and Saturday in Israel | getWeekInfo().weekend; the work week shows the other days |
| 12 or 24 hour clock | "9 AM" in the US, "09:00" in France | resolvedOptions().hourCycle of a formatter with hour: "numeric"; a prop can force either |
| Day header format | Weekday and day number, in the order and punctuation each language uses | Intl.DateTimeFormat with weekday: "short", day: "numeric"; never glue parts together ourselves |
| Digits and text direction | Arabic-Indic digits, right-to-left columns | Intl formats digits; direction comes from the host's dir |
Our own strings ("All day", "+2 more", the announcements) come from a messages prop with plural rules, with English defaults. The week start feeds the range function from chapter 3, so it must be settled before we compute which days are visible: changing it later would shift the whole grid.
Right-to-left
In Hebrew and Arabic the first day of the week is the rightmost column and the time gutter sits on the right. Because boxes are placed with inset-inline-start and the columns are a flex row, setting dir="rtl" flips all of it. What does not flip by itself is script: hit testing reads measured column rectangles, so it is already correct; the keyboard must swap Left and Right; and a bar that continues into next week shows its cut edge on the left. Time still runs top to bottom in every language.
Server rendering without a time zone
The meetings app renders on the server for a fast first paint. The server knows the locale from Accept-Language, but a plain request carries no time zone. If the server places events using its own zone (often UTC), a user in New York sees every meeting four or five hours off until JavaScript runs and moves them.
Worse, React's hydration expects the first client render to match the server HTML exactly. A different time label is a hydration mismatch, which React reports as an error and recovers from by rendering that part again on the client. A different top in a style is worse still: React's docs say mismatched attributes are not guaranteed to be patched, so a box could stay at the server's position. The SSR lesson covers hydration in general.
Decision What does the server render for the grid?
What we pick: Use a known zone when the host has one, and otherwise render the frame on the server and events after mount. The day headers are safe to render on the server because the range is given as plain dates; the "today" highlight and the now line depend on the zone, so they wait for the client too. In React, useSyncExternalStore with a server snapshot of null is a clean way to say "no zone yet" during hydration.
Check your understanding · Exercise 10
Which of these can the server render correctly without knowing the user's time zone (select all that apply)? Select all that apply.
Architecture, data model and testing: a pure core, a thin shell
We now have all the pieces. The architecture question is where to draw the lines between them, and the answer also decides how we test. The rule we follow: everything that can be a pure function of props is one, so it can be memoized and tested with fixed dates and zones; state lives in as few places as possible; and only two parts touch the DOM in a hot path, the interaction controller and the now line.
| Part | Responsibility | Kind |
|---|---|---|
| Range model | From view, date, week start and zone, the visible days and their [dayStart, dayEnd) instants | Pure function |
| Time scale | One step map per day column; yOf and instantAt; cached by zone and date | Pure, cached |
| Segmenter | Clips events to columns, splits at midnight, sends long events to the all-day row | Pure |
| Layout engine | Overlap columns per day; lanes for the all-day row | Pure, memoized per column |
| Pending layer | Optimistic overrides and drafts, with versions | Component state |
| Interaction controller | Pointer and keyboard state machine, ghost, auto-scroll, snapping | Refs plus one small state for the ghost |
| Clock | Minute-aligned now, paused when hidden | Hook, subscribed by two parts only |
| Announcer | One polite live region with queued, short messages | Component |
| Views | Toolbar slot, headers, all-day row, gutter, day columns, event boxes, list view | React components with renderEvent and renderSlot slots |
Why these boundaries? The time scale is separate from the segmenter because the controller, the now line and the keyboard all need instantAt without segments. The layout engine is separate from the views so the list view and the time grid share it, and the pending layer sits between the host's events and the segmenter, so a pending move is laid out exactly like a real one, and dropping the override is all a rollback needs. The host is outside the box entirely: it fetches, caches and saves.
The data model inside the component
type Day = { date: string; dayStart: number; dayEnd: number; steps: Step[] | null };
// steps is null on normal days: the map is a straight line
type EventSegment = {
key: string; // eventId + date
eventId: string;
dayIndex: number;
start: number; end: number; // clipped instants
startsBefore: boolean; endsAfter: boolean;
top: number; height: number; // px, from the step map, min 20
col: number; cols: number; span: number; // from the overlap layout
};
type AllDayBar = { eventId: string; firstDay: number; lastDay: number; lane: number };
type DragState =
| { phase: "idle" }
| { phase: "pressed" | "dragging"; kind: "create" | "move" | "resize-start" | "resize-end";
eventId?: string; ghost: { dayIndex: number; start: number; end: number } }
| { phase: "keyboard"; eventId: string; ghost: { dayIndex: number; start: number; end: number } };
| Data | Lives in | Why there |
|---|---|---|
| Events | Host (server cache), passed as props | The host owns truth, auth and saving |
| View and date | Host, often the URL | Shareable links and back button; we only report changes |
| Days and step maps | Memo cache keyed by zone and date | Pure and reused across renders and weeks |
| Segments and layout | Memo per column | Recomputed only for columns whose events changed |
| Pending overrides | Component state | Must survive re-renders, cleared by promise results |
| Pointer coordinates | A ref | Change 60+ times a second and must not cause renders |
| Ghost position | Small state inside the ghost component | Changes only when the snapped time changes |
| Scroll position | The scroller element, saved in a ref on range change | Kept when moving between weeks |
events in and change callbacks out. The override goes up into the read path, so a pending move is drawn exactly like a saved one.Check your understanding · Exercise 11
Which placements are right (select all that apply)? Select all that apply.
Testing the days that only come twice a year
Most calendar bugs hide in dates nobody tests by hand: the spring forward Sunday, the fall back Sunday, the last day of a year, a zone with a 45 minute offset. Because the core is the pure functions above, taking a zone name and instants, we can test those days on any machine on any date, without waiting for March.
- Table tests for the time scale: for each of
America/New_York,Europe/Berlin,Asia/Kolkata,Asia/Kathmandu(UTC plus 5:45) andAustralia/Lord_Howe(30 minute shift), build every column of a transition week and check thatyOfalways increases, thatinstantAt(yOf(t))returns t, and that gap rows map to nothing. - Segment tests: an event across midnight, one across the spring gap, one of exactly 24 hours, one ending at the start of a day (it must not create an empty segment on that day, because end is exclusive).
- A fixed clock: fake timers set
Date.now()to 23:59:30 to check that the now line and the today highlight move at midnight, and to 01:30 on the fall back night, once in each pass of the repeated hour. - Browser tests in many zones: Playwright's
timezoneIdandlocaleoptions run the same drag test inAmerica/New_Yorkwithen-USandAsia/Jerusalemwithhe-IL, which also covers right-to-left and a Sunday week start. - Accessibility checks: an automated scan for names and roles, plus a scripted keyboard run that moves an event and asserts the live region text.
In production we log one cheap signal per failed save (event id, kind of change, error reason) and the time the drop took to paint. A spike of failed moves after a release usually means a contract change in a host, not a bug in the grid, and the logs tell those apart.
Think first
Why is "run the test suite with the CI machine's TZ set to a different zone" not enough to catch DST bugs in this component?
Because the grid's zone is a prop, not the machine's zone. Changing TZ tests only code that wrongly uses the device zone, which is still worth doing to find stray getHours() calls.
The DST cases need specific dates in specific zones passed in as props: the transition weeks of several zones, built and checked in table tests. Those run the same on every machine.
Running it in the interview
A component question gives you about 60 minutes. The interviewer wants to see that you treat it as a reusable piece with a contract, that you know where time is tricky, and that you can make gestures fast and accessible. Here is a plan that leaves room for their follow-ups.
| Minutes | Do | Draw or say |
|---|---|---|
| 0 to 5 | Scope questions: who embeds it, who owns data, which views, zone, touch, SSR | The requirements table, short |
| 5 to 8 | Name the five hard properties | "Time is not a straight line, the host owns truth, gestures are fast, space is shared, settings vary." |
| 8 to 18 | API: event shape with instants and plain dates, controlled range, render slots, promise callbacks | The props type and the edit contract options |
| 18 to 28 | Coordinate math: per-column map, DST gap and repeated hour, midnight split, all-day lanes | Three columns: normal, 23 hours, 25 hours |
| 28 to 40 | Interactions: controller, threshold, capture, long press, snapping, auto-scroll, Escape, pending layer and rollback | The drag state machine and the move sequence with its failure path |
| 40 to 50 | Performance and accessibility: absolute boxes, render window, drag hot path, keyboard model, announcements | The architecture diagram |
| 50 to 60 | Locales, RTL, SSR, testing, and the interviewer's questions | The SSR timeline if asked |
Draw the wireframe first and keep it on the board; every later topic points at a part of it. Say the numbers out loud: 48 pixels per hour, 15 minute snap, 5 pixel threshold, 250 ms long press, 300 events, 16 ms per frame. Numbers turn opinions into a design.
Follow-up questions to expect
- "Show a second time zone in the gutter." A second gutter column formats each row's instant in the other zone. Rows are wall-clock rows of the grid's zone, so the second gutter can show uneven steps on DST days, which is honest. Placement does not change at all.
- "A team wants a resource view: rooms as columns for one day." The time scale, segmenter and controller don't care what a column means. Columns become resources with the same
[dayStart, dayEnd), and events are grouped by resource instead of by day. With 200 rooms, virtualize the columns horizontally. - "How do you show 40 overlapping events at 9:00?" Cap the sub-columns at about 4 per cluster and show a "+36" chip that opens a list. Boxes narrower than about 40 pixels are unreadable and untappable.
- "Another user moves the event while I am dragging it." The host's
eventschange mid-drag. We keep the drag going on the old times, and on drop the host decides: save, or reject with a conflict reason that we announce. If the event is deleted, the drag cancels. - "Why not use
Dateand the device zone everywhere?" Because the grid zone is a prop. A manager in London planning a New York team's week must see New York hours.Intlwith an explicittimeZoneand instants as numbers keep the device out of it. - "How do you support undo?" The host owns it: each settled change is an action it can reverse with another
onMove. The undo and redo guide covers the command stack; the grid just exposes the changes.
Summary
- Design the component's contract first: timed events as instants, all-day events as plain dates, a controlled range, render slots, and edit callbacks that return promises.
- Every position comes from one per-column map in the grid's own time zone. On DST days it shows the gap as a hatched band and splits the repeated hour into two halves, so every instant has one place and the gutter stays aligned.
- Cut events into segments per day before layout: cross-midnight events become linked pieces, events of 24 hours or more become all-day bars packed into lanes, and overlap layout runs on drawn heights.
- Place boxes absolutely inside day columns, render the current range plus neighbors for swiping, and keep drags off the slow path: coalesce moves into one frame, move a ghost with transforms, lay out once on drop.
- One pointer controller handles create, move and resize with capture, a 5 pixel threshold, long press on touch, wall-clock snapping, auto-scroll and Escape.
- A pending layer of versioned overrides gives instant feedback and a clean rollback, while the host stays the source of truth.
- The now line ticks once a minute on an aligned timer, sleeps in hidden tabs and reads the clock only after hydration; the grid opens at the current time or working hours without a visible jump.
- Keyboard users get an event layer, a slot grid and a list view; screen readers hear day, time range and title, and every move and failure through one live region.
- Week start, weekend, hour cycle and direction come from the locale; the server renders the frame and waits for a known time zone before placing events.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Intl.DateTimeFormat.prototype.formatToParts() Reading wall-clock hour and minute of an instant in any zone.
- MDN: Intl.Locale.prototype.getWeekInfo() First day of the week and weekend days per locale, with browser support.
- MDN: Temporal.ZonedDateTime The coming standard for zoned date and time math, including start of day and DST rules.
- MDN: Element.setPointerCapture() Keeping a drag's events coming after the pointer leaves the element.
- MDN: touch-action Which touch gestures the browser handles itself.
- WAI-ARIA Authoring Practices: Grid pattern Keyboard model and roles for the slot grid.
- Understanding WCAG 2.2: Dragging Movements (2.5.7) Why every drag needs a single-pointer alternative.
- web.dev: Interaction to Next Paint (INP) The responsiveness metric behind the drag and drop budgets.
- React: useSyncExternalStore Server snapshots for values, like the time zone, that only the client knows.
- FullCalendar docs: TimeGrid view A mature open source time grid to compare API choices with: slotDuration, snapDuration, scrollTime, nowIndicator.
- React: hydrateRoot Why the first client render must match the server HTML, and why mismatched attributes may not be patched.
- dnd-kit docs: Touch sensor Long press activation with a delay and tolerance, and why
touch-action: manipulationsuits draggable items.
Concepts behind it
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it.