# An event calendar on shadcn/ui and Base UI: overlap layout, keyboard drag and drop, and eight traps

> How Daybook lays out overlapping events, moves them by mouse, touch and keyboard with dnd-kit, and eight traps we hit in zustand, Base UI, dnd-kit, the React Compiler lint and WCAG.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Daybook — Event Calendar & Scheduler for React, shadcn/ui & Tailwind (Drag & Drop)](https://upfork.dev/products/shadcn-event-calendar-scheduler-react) (Component, comes with the Pro plan). Published 2026-10-26. 7 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Daybook is an event calendar and scheduler for React, built on shadcn/ui with Base UI primitives: month, week, day and agenda views; drag to create, move and resize; repeating events edited as "this one", "this and following" or "all"; attendees with photos and replies; teammates' calendars laid over yours; search, settings and keyboard shortcuts; and Undo on every change. It lays itself out by its own width, so it works full screen or inside a dashboard.

[Live preview of Daybook — Event Calendar & Scheduler for React, shadcn/ui & Tailwind (Drag & Drop)](https://upfork.dev/products/shadcn-event-calendar-scheduler-react)

This article is for anyone, person or AI, building a calendar or any drag-heavy app in React: how overlapping events are placed in a day, how one drag layer serves three different views and the keyboard, and eight traps in zustand, Base UI, dnd-kit, the React Compiler's lint rules and WCAG, each with its fix.

## One store per calendar, read in slices

Each `<EventCalendar />` has its own zustand store. Components read the slices they need, and the occurrences a view shows are expanded from the events (repeats included) and memoised on exactly those inputs:

`components/calendar/hooks.ts`, lines 9–20:

```ts
/** The occurrences a range shows (your calendars that are on, plus overlaid teammates), memoised. */
export function useOccurrences(start: Date, end: Date): Occurrence[] {
	const { events, tz, me, hidden, team } = useCalendar(
		useShallow((s) => ({ events: s.events, tz: s.settings.timeZone, me: s.me, hidden: s.hidden, team: s.team }))
	)
	const from = start.getTime()
	const to = end.getTime()
	return React.useMemo(
		() => visibleIn(expand(events, new Date(from), new Date(to), tz), { me, hidden, team }),
		[events, from, to, tz, me, hidden, team]
	)
}
```

> **Lesson: A zustand selector that returns a new object every call loops forever: wrap it in useShallow** (My own mistake; learned by the AI (Claude Opus 5.5); zustand@5)
> **I assumed:** `useCalendar((s) => ({ events: s.events, tz: s.settings.timeZone }))` picked a few fields, the way a selector does in other stores.
> **What happened:** the selector builds a new object on every call, and zustand 5 compares what a selector returns by reference, so every render saw a "new" value and asked for another: React warned that the result of `getSnapshot` should be cached, and the component rendered in an infinite loop.
> **The fix:** `useShallow` from `zustand/react/shallow` around every selector that returns an object, which compares the object's fields one by one.
> **Evidence:** commit b591bbe; `useShallow` in hooks.ts, month-view.tsx, event-dialog.tsx and the other views.

## Laying out a day of overlapping events

In the week and day views, events that overlap in time share their day's column. The layout groups events into clusters that overlap one another, then gives each event the first column whose last event has already ended:

`components/calendar/logic.ts`, lines 204–244:

```ts
/**
 * Side-by-side columns for overlapping timed events (one day). Events shorter than `minMinutes` count as that
 * long, so a 15-minute meeting never hides under the next one's title.
 */
export function layoutDay<T extends { start: Date; end: Date }>(items: T[], day: Date, minMinutes = 30): Placed<T>[] {
	const dayStart = startOfDay(day).getTime()
	const rows = items
		.map((item) => {
			const top = Math.max(0, (item.start.getTime() - dayStart) / MINUTE)
			const bottom = Math.min(24 * 60, (item.end.getTime() - dayStart) / MINUTE)
			return { item, top, bottom, end: Math.max(bottom, top + minMinutes), col: 0, cols: 1, span: 1 }
		})
		.sort((a, b) => a.top - b.top || b.end - a.end)
	const out: typeof rows = []
	let cluster: typeof rows = []
	let clusterEnd = -1
	const close = () => {
		const ends: number[] = []
		for (const r of cluster) {
			let c = ends.findIndex((e) => e <= r.top)
			if (c === -1) c = ends.push(0) - 1
			ends[c] = r.end
			r.col = c
		}
		for (const r of cluster) {
			r.cols = ends.length
			let span = 1
			while (r.col + span < ends.length && !cluster.some((o) => o.col === r.col + span && o.top < r.end && o.end > r.top)) span++
			r.span = span
		}
		out.push(...cluster)
		cluster = []
	}
	for (const r of rows) {
		if (cluster.length && r.top >= clusterEnd) close()
		cluster.push(r)
		clusterEnd = Math.max(clusterEnd, r.end)
	}
	if (cluster.length) close()
	return out.map(({ item, top, bottom, col, cols, span }) => ({ item, top, bottom, col, cols, span }))
}
```

Events are sorted by start (longer first on a tie). A cluster closes when an event starts after everything in it has ended. Inside a cluster, `ends[c]` holds the end of the last event placed in column c, so an event takes the leftmost column that's free by its start. Each event then stretches right over every column where nothing overlaps it (`span`), so a lone long meeting next to a short one doesn't stay narrow all day. Events shorter than 30 minutes count as 30, so a 15-minute meeting never hides under the next one's title.

Columns are fine on a wide screen. Three at once in a narrow day column are not:

`components/calendar/time-grid.tsx`, lines 305–311:

```tsx
				// Side by side while each keeps ~64px; narrower than that (three at once, phones) they cascade instead,
				// each a little to the right of and above the one before, so every title stays readable.
				const cascade = p.cols > 1 && colWidth / p.cols < 64
				const step = Math.min(14, colWidth / (p.cols + 2))
				const width = (p.span / p.cols) * 100
				const left = (p.col / p.cols) * 100
				const narrow = !cascade && (colWidth * p.span) / p.cols < 60
```

> **Lesson: Overlapping events side by side under about 64 px each break their titles mid-word: cascade them instead** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** splitting a day's width evenly between overlapping events works at any width.
> **What happened:** with three overlapping events in a narrow column (a week on a laptop, or a phone), each got a slice too thin for its title, which broke mid-word.
> **The fix:** when a slice would be under about 64 px, the events cascade instead: each one is drawn a little to the right of and above the one before, nearly full width, with a rising z-index, so every title stays readable and each event can still be grabbed.
> **Evidence:** commit dc47631 ("three overlapping events in a narrow column cascade instead of breaking their titles mid-word").

## One drag layer for three views and the keyboard

Daybook uses dnd-kit with draggables but no droppables. A drag reports how far the pointer has moved, and the view that started it turns that delta into a target: a day in the month grid, a day and a 15-minute slot in the time grid, a day in the all-day row. The views tell the drag layer what one keyboard step is and what the drop would mean, through a small bridge:

`components/calendar/dnd.tsx`, lines 26–69:

```tsx
/**
 * The views tell the drag layer how far one keyboard step goes (a day column, a week row, a 15-minute slot) and
 * what the drop would mean, so arrow keys move by whole slots and screen readers hear "Tue, Oct 20 · 2:00 PM".
 */
export interface DragBridge {
	step: { x: number; y: number }
	label: string
	onMove?: (e: DragMoveEvent) => void
	onEnd?: (e: DragEndEvent) => void
	onCancel?: () => void
}

let lastDrop = 0
/** A drag just ended: the click the pointer-up fires must not open the event. */
export const justDragged = () => Date.now() - lastDrop < 400

/** What a view hands the drag layer when one of its events is picked up. */
export interface DragBridgeApi {
	configure: (b: Omit<DragBridge, "label">) => void
	setLabel: (label: string) => void
}

export const DragBridgeContext = React.createContext<DragBridgeApi | null>(null)

export function useDragBridge() {
	const ref = React.useContext(DragBridgeContext)
	if (!ref) throw new Error("useDragBridge must be used inside <CalendarDnd>")
	return ref
}

export function CalendarDnd({ children }: { children: React.ReactNode }) {
	const store = useCalendarApi()
	const bridge = React.useRef<DragBridge>({ step: { x: 40, y: 14 }, label: "" })
	const api = React.useMemo<DragBridgeApi>(
		() => ({
			configure: (b) => {
				bridge.current = { ...b, label: "" }
			},
			setLabel: (label) => {
				bridge.current.label = label
			},
		}),
		[]
	)
```

The month view, for example, sets the step to one cell and names the target as the drag moves, so a screen reader hears where the event would land:

`components/calendar/month-view.tsx`, lines 124–133:

```tsx
			const rect = gridRef.current?.getBoundingClientRect()
			const span = differenceInCalendarDays(lastDay(data.occurrence), data.occurrence.start)
			bridge.configure({
				step: { x: rect ? rect.width / cols : 80, y: rect ? rect.height / rows : 80 },
				onMove: (ev: DragMoveEvent) => {
					const to = targetOf(data.occurrence, ev.delta.x, ev.delta.y)
					if (!to) return
					setTarget({ from: to, to: addDays(to, span) })
					bridge.setLabel(dateShort(to))
				},
```

A pointer has to move 5 px before a drag starts (so a click stays a click), a finger has to rest 300 ms (so a swipe still scrolls), and the keyboard picks up an event with Space, moves it with the arrows by one step, and drops it with Space or Enter.

> **Lesson: The React Compiler's lint refuses writes to a ref returned by a hook: expose methods that write it instead** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** the views could take the bridge's ref from `useDragBridge()` and set `bridge.current.step` and `bridge.current.label` themselves when they picked up an event.
> **What happened:** the React Compiler's lint rules refuse to let a component mutate a value a hook returned, so every one of those writes was an error.
> **The fix:** the provider keeps the ref and hands out a memoised object with two methods, `configure()` and `setLabel()`, that write it; the views only call them. The ref never leaves the component that owns it.
> **Evidence:** commit b591bbe; `DragBridgeApi` and `CalendarDnd` in dnd.tsx.

> **Lesson: The click after a dnd-kit mouse drop lands on the dragged event: ignore clicks for a moment after a drop** (Not in the docs; learned by the AI (Claude Opus 5.5); @dnd-kit/core@6.3)
> **I assumed:** a drag that ends with the pointer still over the event would not count as a click on it.
> **What happened:** the pointer-up that ends a mouse drag is followed by a click on the event under it, which opened the event, or its hover card, right after it was dropped.
> **The fix:** the drag layer records when the last drag ended, and the event's click and hover handlers do nothing for 400 ms after it (`justDragged()`).
> **Evidence:** commits b591bbe and dc47631 ("pressing an event (or just having dropped one) never opens a hover card"); dnd.tsx lines 38–40.

`components/calendar/dnd.tsx`, lines 38–40:

```tsx
let lastDrop = 0
/** A drag just ended: the click the pointer-up fires must not open the event. */
export const justDragged = () => Date.now() - lastDrop < 400
```

## The event dialog: Escape in the right place

Editing an event opens a dialog full of pickers: people, the repeat rule, start and end times, all Base UI comboboxes and selects with their own lists.

`components/calendar/event-dialog.tsx`, lines 66–73:

```tsx
		<Dialog
			open={open}
			onOpenChange={(v, details) => {
				if (v) return
				// Escape in an open list (people, Repeat, a time) closes only that list.
				if (details?.reason === "escape-key" && document.querySelector('[role="listbox"], [data-slot="combobox-content"], [data-slot="select-content"]')) return
				requestClose()
			}}
```

> **Lesson: Escape in a Base UI Combobox inside a Dialog also closes the Dialog: ignore escape-key while a list is open** (Not in the docs; learned by the AI (Claude Opus 5.5); @base-ui/react@1.9)
> **I assumed:** pressing Escape in an open combobox list would close the list and leave the dialog around it alone, as it does with a single popup.
> **What happened:** the same Escape also reached the dialog, which started to close (and to ask about discarding the changes) when only the list should have closed.
> **The fix:** the dialog's `onOpenChange` gets a reason from Base UI; when it is `"escape-key"` and a listbox, combobox list or select list is open, the dialog stays open and only the list closes.
> **Evidence:** commit b591bbe; event-dialog.tsx lines 68–72.

## Hover cards and quick views that fade out whole

Resting on an event shows a hover card; clicking it opens a quick view (a bottom sheet on phones). Both are Base UI popups that animate out when they close.

`components/calendar/event-popovers.tsx`, lines 102–106:

```tsx
	const live = hover ? commands.find(hover.key) : null
	// The card keeps what it showed while it fades out (it closes before its content would otherwise go blank).
	const [last, setLast] = React.useState<Occurrence | null>(null)
	if (live && live.key !== last?.key) setLast(live)
	const o = live ?? last
```

> **Lesson: A popup whose content comes from state cleared on close goes blank while it fades out: render the last content** (My own mistake; learned by the AI (Claude Opus 5.5); @base-ui/react@1.9)
> **I assumed:** reading the hovered event from the store was enough to fill the hover card and the quick view.
> **What happened:** closing clears that state at once, but the popup stays on screen for its exit animation, so it faded out empty.
> **The fix:** each popup keeps the last event it showed in local state and renders that while the store has none (`const o = live ?? last`), so it fades out with its content.
> **Evidence:** commit dc47631 ("the hover card and quick view keep their content while they fade out").

## Accessible and readable

Every event is a button whose name is what it shows, followed by details for screen readers; month events sit inside grid cells, so the month is a valid grid; and the drag layer announces where an event would land.

`components/calendar/event-item.tsx`, lines 91–92:

```tsx
	// The accessible name is what the event shows plus these details (read after it, hidden on screen).
	const sr = <span className="sr-only">, {details}</span>
```

> **Lesson: An aria-label that replaces an element's visible text fails axe's label-in-name: keep the text and add sr-only details** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** each event could carry a full `aria-label` with its time, place and guests in place of its short visible title.
> **What happened:** that fails axe's label-in-name rule: an accessible name must contain the visible text, so people using speech input can say what they see to activate it.
> **The fix:** no `aria-label`; the name is the event's visible text followed by a visually hidden span with the details (`<span className="sr-only">, {details}</span>`).
> **Evidence:** commit b591bbe; event-item.tsx lines 91–92; axe finds no violations on ten screens in both themes.

Past events are dimmed, but not by transparency:

`components/calendar/bits.tsx`, lines 104–108:

```tsx
/**
 * Past events: the tint and the accent bar fade (a veil of the background over them) while the text turns muted, so
 * it still reads at WCAG AA. Needs the event's own fill without its text colour (`fill`).
 */
export const PAST = "isolate text-muted-foreground before:opacity-60 after:absolute after:inset-0 after:-z-10 after:rounded-[inherit] after:bg-background/45"
```

> **Lesson: Dimming past events with opacity-60 takes their text below WCAG AA: veil the fill and mute the text instead** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** `opacity-60` on past events, as the brief asked, was a safe way to push them back.
> **What happened:** opacity fades the text along with the tint, and the past events' titles fell below the 4.5:1 contrast WCAG AA asks for.
> **The fix:** the tint and the accent bar fade under a veil of the background colour (`after:bg-background/45`, with the fill at 60%), while the text turns `muted-foreground`; axe finds no contrast problems in either theme.
> **Evidence:** commit b591bbe; the comment above `PAST` in bits.tsx.

## Checked

20 unit tests (time zones, the shared formatter, typed times, repeat rules, the overlap layout, the demo data and edits), 29 click-throughs on the production build (create, move, resize, repeat edits, Undo), axe on ten screens in light and dark, and 110 screenshots. On UpFork it passed the importer and the preview builder.

## Make it yours

`<EventCalendar />` takes your `events`, `categories` and `people` in the shape of `types.ts`, `me` for the signed-in person, a starting view, date, overlaid teammates and settings, and `onEventsChange`, called after every change (create, move, resize, edit, delete, Undo) so you can save them. It keeps the view, the date and an open event in the URL (`syncUrl`) and remembers the visitor's choices (`persist`), and it follows your shadcn theme, light and dark. It needs one sonner `<Toaster />` in your app for the Undo toasts.

```bash
npx shadcn@latest add @upfork/shadcn-event-calendar-scheduler-react
```

---

On UpFork: https://upfork.dev/articles/an-event-calendar-on-shadcn-ui-and-base-ui-overlap-layout-keyboard-drag-and
