Frontend
Building a touch-friendly booking calendar from scratch
Why we built Bookatu's drag-to-reschedule calendar on raw pointer events instead of a library, and how the touch model, swim lanes and optimistic updates actually work.
Most calendar UIs assume a mouse on a wide screen. Ours can't. A salon owner runs the day from a phone behind the counter, between clients, with one thumb. The same screen has to show several staff side by side, let you drag a booking to a new time, draw a block to hold an hour off, and stay correct in the salon's own timezone. We tried to make a generic drag-and-drop library do all of that on touch, kept fighting it, and ended up writing the calendar ourselves on raw pointer events.
This post walks through the real decisions in the calendar component and the availability engine behind it: why custom over a library, the touch gesture model, how swim lanes and drops resolve, and how optimistic updates stay fast without lying to the user.
The gist
- Time is absolute-positioned pixels on a 24-hour grid (1.1px per minute), and drags snap to 15-minute slots.
- Everything runs on Pointer Events, so one code path covers mouse and touch.
- Drops resolve the target lane with elementFromPoint over data-colid columns, which sidesteps HTML5 drag-and-drop entirely.
- Drawing a new block on the empty grid is armed by a 250ms long-press so a normal swipe still scrolls; dragging an existing card opts out of scrolling with touch-action: none instead.
- Every action is optimistic with a captured original to revert to, and the server re-checks availability before it commits.
Why not a calendar library
Libraries are good at the common case. The problem is that our case is mostly edges, and the edges are where a library makes you fight its model. We needed multiple staff as columns on one day (swim lanes), drag-to-reschedule that also reassigns staff, draw-on-empty-grid to create a time block, drag handles in the time gutter to adjust opening hours, and all of it working under a thumb on a phone. The mobile story is where off-the-shelf touch handling tends to break: you get either a drag that hijacks page scrolling, or a scroll that won't let you drag.
The layout itself is simple enough that owning it is cheaper than bending a library to it. A day is just minutes mapped to pixels. We fix a scale and position every appointment, block and gridline absolutely:
const PX_PER_MIN = 1.1;
const SLOT_MINS = 15; // drag snap interval
const GRID_START_MIN = 0;
const GRID_END_MIN = 24 * 60; // render the full day, grey the closed hours
function snapToSlot(min: number): number {
return Math.round(min / SLOT_MINS) * SLOT_MINS;
}
// An appointment's box:
const top = (a.startMin - dayStartMin) * PX_PER_MIN;
const height = Math.max(a.durationMin * PX_PER_MIN, 28); // floor so tiny ones stay tappableOnce geometry is that direct, the work isn't drawing the grid. It's the gestures, and a library doesn't help with the gestures we actually need.
One pointer model for mouse and touch
The whole calendar runs on Pointer Events. Not separate mouse and touch handlers, and not HTML5 draggable. A drag is a small state machine started in onPointerDown: record the start point, then in pointermove decide whether the gesture is a real drag or just a tap. We use a 6px slop before we treat a move as a drag, so a slightly shaky tap doesn't get read as a reschedule.
onPointerDown={(e) => {
if (!isDraggable || (e.pointerType === "mouse" && e.button !== 0)) return;
const node = e.currentTarget as HTMLElement;
const sx = e.clientX, sy = e.clientY;
let moved = false;
const move = (ev: PointerEvent) => {
if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) > 6) {
moved = true;
node.style.pointerEvents = "none"; // so elementFromPoint sees the columns, not the card
onDragStart(a.id);
}
if (moved) {
ev.preventDefault();
const t = targetFromPoint(ev.clientX, ev.clientY);
if (t) onDropTargetChange(t);
}
};
const up = (ev: PointerEvent) => {
/* remove listeners */
node.style.pointerEvents = "";
if (!moved) { onSelect(a.id); return; } // no drag, a tap selects
const t = targetFromPoint(ev.clientX, ev.clientY);
if (t) onDrop(a.id, t.dateKey, t.startMin, t.colId);
};
window.addEventListener("pointermove", move, { passive: false });
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
}}The card itself carries touch-action: none, so on a phone the drag starts at the 6px slop instead of being eaten by the scroller. The line that matters most on touch is the one generic solutions miss: when a drag starts, we set the dragged card's pointerEvents to none. That lets document.elementFromPoint report the column under the finger instead of the card you're holding. We don't track which lane you're over by maths, we ask the DOM. Each column carries a data-colid, so targetFromPoint walks up from the point to the nearest column and converts the y position back into a snapped minute. That one trick makes cross-lane drops behave the same on a trackpad and a thumb.
function targetFromPoint(clientX: number, clientY: number) {
const el = (document.elementFromPoint(clientX, clientY) as HTMLElement | null)
?.closest("[data-colid]") as HTMLElement | null;
if (!el) return null;
const raw = el.dataset.colid ?? "";
const rect = el.getBoundingClientRect();
const min = snapToSlot(Math.max(dayStartMin, dayStartMin + (clientY - rect.top) / PX_PER_MIN));
return { dateKey: date, startMin: min, colId: raw === "" ? null : raw };
}Drag versus scroll: the hardest touch problem
The genuinely hard part on a phone is drawing a new time block on the empty grid. A vertical finger drag there is ambiguous: it could mean scroll the day, or draw a block from 2pm to 3pm. You can't know which until the user commits, and if you guess wrong the page either jumps or freezes. (An existing card has no such ambiguity, since its own touch-action: none already claims the gesture, which is why only the empty grid needs the trick below.)
We resolve it the way Google Calendar does. On touch, the draw isn't armed immediately. A normal swipe scrolls. A press-and-hold of 250ms arms the draw, and only then do we claim the gesture: set touch-action to none on the column and scroller, clear any text selection the long-press kicked off, and capture the pointer. If the finger moves past the tap slop before the hold fires, we treat it as a scroll and stand down. A mouse arms instantly, because a mouse has no scroll-versus-drag ambiguity.
let armed = !isTouch; // mouse arms now; touch waits for the hold
const arm = () => {
armed = true;
colEl.style.touchAction = "none"; // claim the gesture from the scroller
if (scroller) scroller.style.touchAction = "none";
window.getSelection?.()?.removeAllRanges?.();
document.body.style.userSelect = "none";
colEl.setPointerCapture(pointerId);
};
const armTimer = isTouch ? window.setTimeout(arm, DRAW_LONG_PRESS_MS) : 0;Once armed, a block can grow past the visible screen. An edge auto-scroll runs on requestAnimationFrame: when the finger nears the top or bottom of the scroller, it scrolls the grid and keeps extending the selection. Because the grid slides under a stationary finger, we re-read the column's bounding rect on every frame rather than caching it once, or the mapped time drifts as you scroll. A capture-phase scroll listener recomputes the selection end even when no pointermove fires, so the box keeps tracking the grid during momentum scroll. On touch, a quick tap with no drag opens a default one-hour block-or-book chooser. These are the small behaviours a library can't know you want.
Swim lanes that collapse on a phone
On desktop, each staff member gets a column, plus an Unassigned lane that only appears when there's an unassigned booking. Side by side those lanes are fine on a wide screen and unusable on a phone, so on mobile we render one full-width lane at a time with a chip switcher above it. The desktop columns are hidden with a responsive class rather than unmounted, so the same component tree drives both.
An org-wide block, one with staffId null, would naively render a duplicate copy in every column. Instead we draw it once as a single band spanning all lanes, clearly labelled as blocking everyone. It stays draggable vertically to change its time and stays org-wide however far sideways you drag it. Small thing, but a per-column copy read as a bug to every user who saw it.
Optimistic updates that don't lie
Every mutation (reschedule, status change, block move, hours edit) updates local state first and reverts on failure. The pattern is always the same: capture the original row, apply the change locally, call the server action, and on a non-ok result put the original back and show a toast. The user sees an instant move; if the server disagrees, it snaps back with a reason.
const orig = appts.find((a) => a.id === apptId);
setAppts((prev) => prev.map((a) =>
a.id === apptId ? { ...a, startMin: snapped, startAtISO: newStartIso, dateKey, staffId } : a,
));
const res = await rescheduleAppointmentAction(slug, apptId, newStartIso, newStaffId);
if (!res.ok) {
setAppts((prev) => prev.map((a) => (a.id === apptId ? orig! : a))); // revert
showToast(res.error ?? "Could not reschedule. Try again.", false);
}The detail that earns the optimism is timezone handling. A drop gives us a date and a snapped minute in the salon's local time, and we convert that to the correct UTC instant before sending it. An earlier version assumed UTC midnight, so 2pm in New Zealand became 2pm UTC, which is 2am local, and the slot then read as unavailable on the server. Booking times are exactly where naive date maths bites, so the calendar never trusts the browser's clock. It always converts through the org's timezone.
Optimism is only safe because the server is the real authority. The client lets you drop almost anywhere, but the server's resolveBookingSlot recomputes that day's availability from scratch and confirms the exact slot still exists before it commits. The engine buckets appointments and blocks by local day in one pass, builds busy intervals per staff member with a configurable buffer, and splits a service that has processing time into two hands-on intervals, so the staffer stays bookable during, say, colour developing. The UI can be loose because the engine behind it is strict.
// Server re-check at booking time (simplified)
const day = (await getAvailability({ /* org, service, duration, from/to = key */ }))[0];
if (!day || !day.open) return { ok: false, reason: "Salon closed" };
const slot = day.slots.find((s) => s.startMin === startMin);
if (!slot) return { ok: false, reason: "Time no longer available" };
return { ok: true, staffId: params.staffId ?? slot.staffIds[0] };Accessibility wasn't free, and it isn't done
Drag interfaces are hostile to keyboards by default, so the gestures aren't the only way in. The opening-hours edges are real drag handles, but they're also role=slider with aria-valuenow and arrow-key support, so you can adjust open and close times without a pointer at all. Appointment cards are focusable, with Enter and Space to open the detail panel, and the panel lets you reassign staff without dragging, which doubles as the easy path on a small screen. Toasts announce through an aria-live region.
The geometry was a day. The gestures were a month. That ratio is the whole argument for and against building your own calendar.
Would we do it again? For this product, yes. The cost of a custom calendar is concentrated in the touch state machines: the arm-on-hold logic, the auto-scroll, the elementFromPoint drop resolution. You don't escape that cost with a library so much as relocate it into fighting the library's assumptions. What's left is honest: keyboard reordering of appointments themselves, better screen-reader narration during a drag, and a week view that's as comfortable to drag on as the day view. None of it changes the core bet, which is that when the interaction is the product, owning the interaction is worth it.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs