Events and interaction

Documentation index

Dates and identifiers

EventInput.start is required. Provide a stable, unique id whenever events can be updated or refetched. IDs normalize to strings; reusing one ID makes lookups and replacement ambiguous.

InputMeaning
'2026-09-07'An all-day date, unless allDay explicitly changes the interpretation
'2026-09-07T10:00'Wall-clock time in the calendar's timeZone
'2026-09-07T14:00Z' or a timestamp with an offsetAn instant, displayed in the calendar's zone
Native Date, epoch milliseconds, or Temporal valuesAccepted date inputs; returned values are Temporal

Changing the timezone preserves wall-clock time for offset-free inputs and preserves the instant for UTC/offset inputs. Callbacks and getEvents() return CalEvent objects, with start/end as Temporal values, plus startStr/endStr for serialization. Use Temporal methods such as toString() or add(); these are not Date objects with getTime().

All-day ends are exclusive: September 7–9 inclusive is { start: '2026-09-07', end: '2026-09-10' }. A date-only event with no end lasts one day. A timed event with no end is a point event; defaultTimedEventDuration applies when an all-day event is dropped into a time grid, not to every missing timed end. Give timed events an explicit end when a visible duration matters.

Common optional fields: title, resourceIds, color, backgroundColor, textColor, editable, startEditable, durationEditable, classNames, and extendedProps. Put application metadata in extendedProps. Use display: 'background' to shade a range instead of showing an ordinary event.

Load an array or remote source

Use events for a local array. Use eventSources for remote data, as a URL descriptor or a function. A function must return a Promise<EventInput[]> or call its success/failure callbacks; a bare synchronous array is not its return contract. When sources are configured, their results replace the store rather than append to the static events array; use one input authority.

import type { CalendarOptions, EventInput, EventSourceFunc } from 'z-cal'

const loadEvents: EventSourceFunc = async ({ startStr, endStr, timeZone }) => {
  const query = new URLSearchParams({ start: startStr, end: endStr, timeZone })
  const response = await fetch(`/api/events?${query}`)
  if (!response.ok) throw new Error(`Loading events failed: ${response.status}`)
  // This example assumes your endpoint returns a validated EventInput[] JSON array.
  const events: EventInput[] = await response.json()
  return events
}

export const options: CalendarOptions = {
  eventSources: [loadEvents],
  loading: (busy) =>
    document.querySelector('#calendar-status')?.setAttribute('aria-busy', String(busy)),
}

For a simple JSON feed, use eventSources: [{ url: '/api/events' }]. The endpoint returns a JSON array, not an { events: [...] } envelope. The built-in loader supplies start, end, and timeZone; GET/HEAD append query parameters, other methods send URL-encoded form data. URL sources accept method and extraParams. For custom headers, credentials, validation, or an error banner, use a function source and your own fetch implementation.

lazyFetching defaults to true: a previously covered range can be reused. refetchEvents() invalidates the cache. Replacing a source invalidates its cache even for the same dates. fetchPadding (default false) extends the requested range by one view duration before and after the active range, so with lazy fetching the adjacent period is served from the cached fetch instead of a new request; with lazyFetching: false every navigation fetches the padded range. Remote refreshes retain current events until all sources settle, then apply one snapshot; stable IDs keep existing elements mounted. Failed sources log errors and contribute no events to a partially successful refresh. If all sources fail, the current store is retained. Use refetchEvents() to retry; surface errors in your function source when users need feedback.

Add, replace, and remove

The imperative API mutates the calendar's current store. It does not write to a server or durable storage. API edits survive ordinary navigation, but replacement arrays and subsequent remote fetches are authoritative. When using remote data, save changes before refetching.

import type { Calendar } from 'z-cal'

export function editMeeting(calendar: Calendar) {
  calendar.addEvent({
    id: 'meeting',
    title: 'Planning',
    start: '2026-09-07T09:00',
    end: '2026-09-07T10:00',
  })
  // Replacement, not a partial patch: supply start and every field you want to preserve.
  calendar.updateEvent({
    id: 'meeting',
    title: 'Planning (updated)',
    start: '2026-09-07T09:00',
    end: '2026-09-07T10:00',
  })
  const event = calendar.getEventById('meeting')
  calendar.removeEventById('meeting')
  return event
}

addEvent returns a CalEvent; updateEvent returns it or null when the ID is missing. getEvents() returns the currently loaded store, not every event on your server.

Choose one owner for updates. In React, either keep events in application state and replace it intentionally, or use the instance API and keep the input array stable. Creating a new events array on each render can overwrite imperative changes. See React data ownership.

Recurring events

Add the Recurrence plugin and give an event a recurrence rule. The calendar expands the rule into occurrences for the visible range each time the range changes, so a series with no end costs only what is on screen and nothing is pre-expanded in your data.

import { createCalendar, DayGrid, TimeGrid, Interaction, Recurrence, type EventInput } from 'z-cal'

export const standup: EventInput = {
  id: 'standup',
  title: 'Standup',
  start: '2026-09-14T09:00',
  end: '2026-09-14T09:15',
  // RFC 5545 text, as most backends store it …
  recurrence: { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20261218T140000Z' },
}

export const retro: EventInput = {
  id: 'retro',
  title: 'Retro',
  start: '2026-09-08T15:00',
  end: '2026-09-08T16:00',
  // … or the equivalent object form: monthly on the second Tuesday.
  recurrence: { freq: 'monthly', weekday: 2, nth: 2, exdates: ['2026-12-08T15:00'] },
}

export function mount(host: HTMLElement) {
  return createCalendar(host, {
    plugins: [DayGrid, TimeGrid, Interaction, Recurrence],
    options: { events: [standup, retro], editable: true },
  })
}

The event start is the anchor: the first occurrence when it matches the rule, and the wall clock of every occurrence. end sets the duration of every occurrence. rrule accepts a bare FREQ=… value, an RRULE: line, or several lines with DTSTART (its TZID sets the zone; the value is ignored in favour of start) and EXDATE. Supported parts are FREQ (DAILY, WEEKLY, MONTHLY, YEARLY), INTERVAL, COUNT, UNTIL, BYDAY (with ordinals such as 2TU and -1FR), BYMONTHDAY, BYMONTH, BYSETPOS, and WKST. Sub-daily frequencies, BYHOUR and finer, BYWEEKNO, BYYEARDAY, and RDATE throw a ZCalParseError rather than silently changing the series. The object form covers the same ground:

FieldMeaning
freq'daily', 'weekly', 'monthly', or 'yearly'
intervalPeriod multiplier, default 1
weekdaysWeekdays, 0 = Sunday; weekly: the days, monthly/yearly: every such weekday (BYDAY)
weekday, nthMonthly/yearly "nth weekday": { weekday: 2, nth: 2 } is the second Tuesday, nth: -1 the last
monthDays, monthsBYMONTHDAY (negative counts from the end) and BYMONTH
setPositions, weekStartBYSETPOS and WKST (default Monday, for interval > 1 weekly alignment)
count, untilGenerated occurrences including the anchor and before exdates; until is inclusive
exdatesOriginal starts to drop; a date-only value drops every occurrence on that day
overridesPer-occurrence changes keyed by occurrence key (below)
timeZoneAnchor zone for timed rules; default the calendar timeZone when the series is parsed

Rules follow RFC 5545: a monthly rule anchored on the 31st skips months without a 31st, and a yearly rule anchored on February 29 skips non-leap years. A date-only until includes the whole day. count counts generated occurrences, so removing one with exdates does not pull a later one in.

Time zones and daylight saving

Timed occurrences are computed as wall clock in the rule zone, then shown in the calendar zone. A 09:00 standup stays at 09:00 through both transitions, its duration stays one civil hour, and an anchor inside a spring-forward gap moves to the next valid time on that day only. Set recurrence.timeZone when the series belongs to a fixed zone (a Tokyo meeting viewed from New York); without it, floating inputs re-anchor in the new zone when the calendar timeZone changes, and instant inputs keep their instants.

Occurrence identity

Every occurrence is a CalEvent whose id is the series id and whose occurrence says which one it is, in every callback (eventClick, eventDrop, eventContent, tooltips) and in getEventOccurrences():

import type { CalendarOptions } from 'z-cal'

export const options: CalendarOptions = {
  eventClick: ({ event }) => {
    if (!event.occurrence) return // a plain event
    // event.id === 'standup'
    // event.occurrence.key === '2026-09-16T09:00:00'  original start, wall clock in the rule zone
    // event.occurrence.index === 2                    position in the series (0 = anchor)
    // event.occurrence.overridden                     an override applies to this occurrence
    // event.recurrence                                the normalized rule, with a canonical `rrule`
  },
}

The key is the original start as wall clock in the rule zone (2026-09-16T09:00:00, or 2026-09-16 for all-day series). It is stable across calendar zone changes and DST, indexes overrides, and appears on event elements as data-cx-occurrence, so getEventElements('standup', key) returns the elements of one occurrence. getEvents() and getEventById(seriesId) return each series once, as its master at the anchor; getEventOccurrences(range?) returns what the views show.

An override is a partial event input applied to one occurrence: move it, retitle it, recolor it. Without its own end it keeps the series duration. A moved occurrence renders where it moved and nowhere else, even outside the weeks its rule generates:

import type { EventInput } from 'z-cal'

export const withExceptions: EventInput = {
  id: 'standup',
  start: '2026-09-14T09:00',
  end: '2026-09-14T09:15',
  recurrence: {
    rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
    exdates: ['2026-11-27T09:00'],
    overrides: {
      '2026-09-16T09:00:00': { start: '2026-09-17T11:00', title: 'Standup (moved)' },
      '2026-09-18T09:00:00': { color: '#dc2626' },
    },
  },
}

Editing occurrences

Dragging or resizing an occurrence never decides scope: it writes an override for that occurrence on the series (the "this occurrence" result), so the view is correct immediately and revert() puts back the override that existed before the gesture. eventDrop.event.occurrence tells you which occurrence moved. The app then asks the user for scope and applies pure helpers to its own series input; the calendar exports them from z-cal and z-cal/recurrence:

import {
  calEventToInput,
  editRecurringEvent,
  type Calendar,
  type EventDropInfo,
  type RecurrenceScope,
} from 'z-cal'

declare function askScope(): Promise<RecurrenceScope | null>

export async function onDrop(calendar: Calendar, info: EventDropInfo): Promise<void> {
  const moved = info.event
  if (!moved.occurrence) return // plain event: persist moved.startStr / moved.endStr
  const scope = await askScope()
  if (!scope) return info.revert()
  const series = calEventToInput(calendar.getEventById(moved.id)!)
  const { update, add } = editRecurringEvent(series, moved, scope, {
    newId: `${moved.id}-${moved.occurrence.key}`,
  })
  if (update) calendar.updateEvent({ ...update, id: moved.id })
  else calendar.removeEventById(moved.id)
  if (add) calendar.addEvent(add)
  // Persist `update` (or the removal) and `add` with the same two writes.
}
  • 'this' returns the series with the override merged over any existing one for that key, which is what the drop already applied.
  • 'following' returns update, the series truncated before the occurrence (until set to the previous occurrence, or count reduced), and add, a new series starting at the moved occurrence. Splitting at the first occurrence returns update: null: remove the series and add the new one.
  • 'all' shifts the whole series by the drag delta: the anchor, until, and every exception move with it, and the weekday or day-of-month pattern moves so the shape is preserved (a Monday/Wednesday series dragged to Tuesday becomes Tuesday/Thursday).

Exceptions after a split point are shifted and re-keyed onto the new series. The helpers keep the input syntax: a series written as rrule text comes back as text, an object rule as an object, and endpoints follow the series' floating or instant style. overrideOccurrence, splitSeries, and shiftSeries are the individual helpers. React owners map their events state the same way; see React data ownership.

Server-expanded series

If your server already expands series, keep doing so and tag each instance with recurringEventId and originalStart (the original occurrence start; defaults to start). Occurrences then report occurrence.seriesId, key, and overridden, with index undefined, and commit like plain events. The Recurrence plugin is not needed for this mode. Without the plugin, a recurrence rule renders its anchor once and warns.

Select to create

Register Interaction, enable selectable, and handle select. Selection alone does not create an event or open a built-in editor. This compact example uses a browser prompt; replace it with your application's accessible form and save flow. A resource selection includes info.resource.

import 'z-cal/polyfill'
import { Calendar, DayGrid, TimeGrid, Interaction, useCalendar, type EventInput } from 'z-cal/react'
import 'z-cal/style.css'

const plugins = [DayGrid, TimeGrid, Interaction]
const initialEvents: EventInput[] = []
export function EventCreator() {
  const { ref, calendar } = useCalendar()
  return (
    <Calendar
      ref={ref}
      plugins={plugins}
      events={initialEvents}
      view="timeGridWeek"
      height="650px"
      selectable
      select={(info) => {
        const title = window.prompt('Event title')?.trim()
        if (title) {
          calendar?.addEvent({
            id: crypto.randomUUID(),
            title,
            start: info.start,
            end: info.end,
            allDay: info.allDay,
            resourceIds: info.resource ? [info.resource.id] : [],
          })
        }
        calendar?.unselect()
      }}
    />
  )
}

For a custom form that stays open, unselectAuto: false retains the highlight until you call unselect() on save or cancel. Alternatively, use unselectCancel with a selector for your editor.

selectConstraint must accept the current candidate for a selection to complete. A rejected candidate clears the preview and does not fire select; moving back to an allowed range can restore it. The constraint runs again on pointer release, so a rule changed during the gesture can reject the selection. Disabling selectable before release also cancels it.

With keyboardNavigation, Shift+Arrow previews a selection from the focused cell or slot, Enter completes it, and Escape cancels it; select then carries the KeyboardEvent as jsEvent. See keyboard navigation.

The playground includes a form with focus management, title validation, cancel, and Escape dismissal. Its events are in memory and reset on reload.

Drag, resize, and persist

Minimum timed event height

eventMinHeight sets a minimum height in CSS pixels for timed foreground events and their drag/resize previews in time-grid and resource time-grid views. It defaults to 0 (duration-based sizing). For example, { slotDuration: '00:15', slotHeight: 14, eventMinHeight: 28 } keeps a 15-minute event readable at 28px. The value must be finite and nonnegative.

Timed events adapt their content to their height. Under two lines of text the title and time share one line, and under one line the text shrinks, so 30 and 15 minute events at the default slotHeight stay readable. Custom eventContent keeps this behavior when it uses the cx-event-title and cx-event-time class names.

Expanded boxes participate in collision packing. Near slotMaxTime, boxes shift upward to stay inside the grid; a minimum taller than the full visible range is capped to that range. Actual event dates and callback deltas remain unchanged by expansion. Background events, selection ranges, and pointer helpers retain exact time geometry. Colliding boxes use the adaptive overlap layout described below. This option does not affect month, list, all-day, or horizontal timeline events.

Overlapping timed events

Time-grid and resource time-grid views keep every foreground event on the grid. Groups requiring two or three lanes use staggered overlapping cards. Groups requiring four or more lanes use narrow side-by-side strips so every event remains exposed and independently selectable. Packing follows start time (using eventOrder to break equal-start ties), reuses lanes as events end, and expands cards across adjacent lanes that stay free for their entire rendered height. This keeps chains of overlaps from wasting columns and gives quieter portions of a cluster more space. No timed events are replaced by overflow controls. Actual start/end times and drag/resize geometry stay unchanged.

Hover an event for 200ms or focus it with the keyboard to see its full title, date, time range, and resource (when applicable). The read-only preview uses the browser's top layer and stays within the viewport without resizing or moving the event cards. It shows both endpoints even when displayEventEnd is false and wraps long titles. You can move the pointer onto the preview to read it; Escape dismisses it. Hover previews close on scrolling, resizing, pointer press, or editing. Keyboard previews follow their focused card when focusing causes the grid to scroll. With keyboardNavigation, Tab moves from a focused column into that date's cards, and Escape on a card closes its preview and returns focus to the column.

Click, tap, Enter, and Space retain the normal eventClick behavior and close the preview. A clicked card stays quiet afterwards, even when the pointer returns to it or it keeps focus, until the pointer enters another card or focus leaves it. Dragging and resizing operate on the original card. Touch presses do not trigger a hover preview; tapping opens the application's normal event details. Background events, selection helpers, and all-day rows do not show previews.

eventTooltip is true by default. Set it to false to turn previews off, or pass a predicate (info) => boolean that receives the public event, its card el, and the view. Return false for an event whose details the application already shows, such as one with an open popover, so the preview and the popover never stack. An open preview closes when the predicate starts refusing it.

eventContent customizes grid cards; eventTooltipContent independently customizes their preview. The default preview reads the event's title and actual time range. Theme key eventTooltip controls the preview surface class. The former slotEventOverlap setting has been replaced by the automatic layout described above.

Customize or replace event previews

eventTooltipContent(info) receives EventTooltipContentInfo: the public event (including extendedProps), view, original card el, formatted dateText and timeText, and the column's resource when applicable. timeText includes both endpoints even when displayEventEnd is false. The callback receives current event data when the preview opens and reruns when the event or formatting changes. Replacing an event card closes its preview; reopening uses the replacement event’s data.

In vanilla JavaScript, return text, { html }, or { domNodes }, following the other content options. Static content is also accepted. Returning undefined restores the default content.

import { createCalendar, TimeGrid } from 'z-cal'

const calendar = createCalendar(document.createElement('div'), {
  plugins: [TimeGrid],
  options: {
    eventTooltipContent: ({ event, dateText, timeText }) => {
      const content = document.createElement('div')
      content.textContent = `${String(event.title)}${dateText}, ${timeText}`
      return { domNodes: [content] }
    },
  },
})
calendar.destroy()

React accepts a renderer returning JSX, with the same typed info and React context preserved:

import { Calendar, TimeGrid } from 'z-cal/react'

export function CustomPreviewCalendar() {
  return (
    <Calendar
      plugins={[TimeGrid]}
      eventTooltipContent={({ event, dateText, timeText, resource }) => (
        <div>
          <strong>{String(event.title)}</strong>
          <p>
            {dateText} · {timeText}
          </p>
          {resource && <p>{String(resource.title)}</p>}
        </div>
      )}
    />
  )
}

Omit the React renderer to restore the default preview. Returning null renders an empty slot, consistent with the other React content slots. Content mounts only while a preview is open and unmounts on dismissal, event removal, disabling, or calendar destruction. The calendar handles positioning, size changes, hover delay, focus, and dismissal. Default typography applies only to the default content; custom content inherits the surface styles and can supply its own typography.

Set eventTooltip: false (React: eventTooltip={false}) to disable the built-in preview entirely. eventMouseEnter, eventMouseLeave, eventClick, and event lifecycle callbacks remain available for consumers implementing their own UI. The built-in preview retains read-only tooltip behavior; use a consumer-owned popover for buttons, forms, or different interaction and dismissal rules.

Anchor your own popover to an event

eventClick hands over the clicked element, but that element is one segment of the event and the calendar re-creates elements when the event moves or the view changes. getEventElements(id) returns the live elements for an event whenever you need to re-anchor, and pointerDown tells you what a press hit so dismissal does not depend on reading the calendar's DOM:

import { createCalendar, Interaction, TimeGrid, type Calendar } from 'z-cal'

interface Popover {
  id: string
  moveTo(rect: DOMRect): void
  close(): void
}

declare function openPopover(id: string, rect: DOMRect): Popover

let popover: Popover | null = null

// The first element is the first visible segment; during a drag it is the ghost left in place.
function reanchor(calendar: Calendar): void {
  if (!popover) return
  const [el] = calendar.getEventElements(popover.id)
  if (el) popover.moveTo(el.getBoundingClientRect())
  else popover.close()
}

const calendar = createCalendar(document.querySelector('#calendar')!, {
  plugins: [TimeGrid, Interaction],
  options: {
    view: 'timeGridWeek',
    editable: true,
    eventClick: (info) => {
      popover?.close()
      popover = openPopover(info.event.id, info.el.getBoundingClientRect())
    },
    pointerDown: (info) => {
      // A press anywhere but on the open popover's own event dismisses it.
      if (!popover || (info.target === 'event' && info.event.id === popover.id)) return
      popover.close()
      popover = null
      // A press that only dismisses does nothing else: consuming it keeps the click from also
      // selecting a slot or emitting dateClick. A press on another event is left alone so the
      // click that follows opens that event's popover.
      if (info.target !== 'event') info.consume()
    },
    eventDrop: () => reanchor(calendar),
    eventResize: () => reanchor(calendar),
    datesSet: () => reanchor(calendar),
  },
})

Position with the element's rect, or hand the element to a positioning library as its anchor. An event that spans several rows or days has several elements; pick the one nearest the pointer with jsEvent or anchor to the first. Presses outside the calendar are yours to observe on document. consume() swallows one press only (no gesture, no click, no long press for it); whether a press on another event should dismiss and open, or only dismiss, is the handler's call per press.

Widen a crowded day

In ordinary time-grid views with multiple days, click a date header to widen that day while keeping the neighboring dates visible. Click it again to restore equal widths, or select another date to move the emphasis. The selected header is highlighted and exposes its pressed state to assistive technology. Enter and Space toggle a focused header; Escape on the header restores the week.

Headers, all-day events, timed events, and the now indicator share the same column widths. Dragging and resizing use the widened grid without changing the displayed date range or scroll position. Navigating to another range or changing the number of visible days clears the emphasis. A configured columnWidth is temporarily overridden and restored when the day is collapsed. Single-day and resource time-grid views do not offer this toggle. Theme key dayFocusButton customizes the header button; buttonText.expand and buttonText.collapse label its action.

Editing and persistence

Register Interaction and enable editable. Selection is separate and requires selectable. Editability resolves from event startEditable/durationEditable → event editable → calendar eventStartEditable/eventDurationEditable → calendar editable (default false). eventResizableFromStart controls the start resize handle. snapDuration controls snapping and falls back to the slot duration. Touch interactions use longPressDelay and its event/selection overrides.

List views let you drag events between visible day buckets using wall-clock date shifts. A grip appears on editable rows when hovered or focused; during a drag, the source dims and the destination shows a highlighted header and event preview without shifting the list. List rows do not expose duration resize handles; use a grid or timeline view to resize events.

Pressing Escape during selection, dragging, or resizing cancels the active gesture and removes its preview. Releasing the pointer afterward does not fire select, eventDrop, or eventResize. An event gesture that already fired eventDragStart or eventResizeStart fires the matching stop callback on cancellation, with the Escape KeyboardEvent as jsEvent. Escape also cancels a pending touch long press or keyboard selection preview. Completed selections remain under your form's control; clear them with unselect(), which Escape on a focused cell also calls.

eventDrop and eventResize run after a local change. Each includes the previous event and a revert() function. A gesture applies its change onto whatever revision of the event is current when the pointer is released. If that event was removed during the gesture, or its dates, all-day flag, resources, or time zone changed (through updateEvent, a replacement events array, a source refresh, or timezone reparsing), release cancels the commit, clears the preview, and emits the matching stop callback without eventDrop or eventResize. Other changes to the event, such as a title update or a refresh that returns the same dates, are kept: the moved event carries them, and oldEvent describes the revision the change was applied to. Changes to other events never cancel a gesture. Release also re-checks editable and the drag/resize constraint against the final range. A gesture that ends at the original position, or whose every candidate was rejected, emits only the stop callback and leaves the event untouched.

revert() restores the previous dates and resources only while the gesture's committed dates are still current, and keeps any other fields changed since. It does nothing after a newer move, deletion, or timezone change; repeating a successful revert also does nothing. Revert affects the local store; it does not undo a server write or update an application-owned React events array.

Timed drags shift both endpoints by the same wall-clock offset, so an interval that spans a DST transition changes length by the offset difference, and changes back when moved away. Two cases are corrected. An endpoint that lands in a fall-back fold is resolved to whichever of its two occurrences keeps the elapsed duration. A move that would invert or collapse a positive interval keeps the shifted start and restores the elapsed duration. For example, in America/New_York, November 1, 2026 at 01:45-04:0001:15-05:00 lasts 30 minutes; moving it one day yields November 2 at 01:45 → 02:15, and moving that back yields November 1 at 01:45-05:0002:15-05:00, still 30 minutes. The preview, constraint payload, and committed event use the corrected range. delta describes the civil drag offset; persist the resulting event endpoints. Point events remain zero-duration events.

This recipe requires an application endpoint accepting the serialized fields:

import type { CalendarOptions, EventDropInfo, EventResizeInfo } from 'z-cal'

async function persist(info: EventDropInfo | EventResizeInfo): Promise<void> {
  try {
    const event = info.event
    const response = await fetch(`/api/events/${encodeURIComponent(event.id)}`, {
      method: 'PATCH',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        start: event.startStr,
        end: event.endStr,
        allDay: event.allDay,
        resourceIds: event.resourceIds,
      }),
    })
    if (!response.ok) throw new Error(`Saving event failed: ${response.status}`)
  } catch (error) {
    info.revert()
    console.error(error) // Replace with your application's visible error feedback.
  }
}

export const options: CalendarOptions = {
  editable: true,
  eventDrop: (info) => {
    void persist(info)
  },
  eventResize: (info) => {
    void persist(info)
  },
}

For production, serialize writes per event or use server-side version checks so requests arriving out of order cannot overwrite newer server data. The local revert guard does not order network requests. For application-owned React data, reconcile failures in that state as well. eventDrop and eventResize report committed edits; returning false from these notifications does not reject the edit. Use the constraint and overlap options below to reject candidates.

Constraints and overlap

dragConstraint, resizeConstraint, and selectConstraint run synchronously on every pointer move and again on release. Each accepts:

  • A predicate of the candidate (EventDropInfo, EventResizeInfo, or SelectInfo); return false to reject.
  • 'businessHours': the candidate must lie inside the calendar's businessHours, or the target resource's own hours. A timed range must fit one continuous window (windows that pass midnight chain into the next day); an all-day range needs business time on every day it covers. With the option off this rejects every candidate and warns once.
  • Inline hours: one rule or an array of { daysOfWeek, startTime, endTime }, checked the same way without shading the grid.
  • An event id: the candidate must stay inside that event's range. Unknown ids reject.

Per event, constraint takes the same non-predicate forms and replaces the drag and resize options for that event; CalEvent.constraint reports it with inline times in seconds from midnight.

eventOverlap (edits) and selectOverlap (selections), both true by default, decide whether the candidate may intersect the visible events after eventFilter. false blocks on any intersecting event; a predicate runs per intersecting event and receives (stillEvent, movingEvent) for edits, with the moving event at its candidate position, or (event) for selections. Per event, overlap: false blocks whether that event is the still or the moving side. The moving instance never blocks itself, helpers never block, background events block only with overlap: false, and events in other resources never block. Zero-duration events block the instant they sit on.

A rejected drag or resize keeps the preview at the event's origin and commits nothing; a rejected selection drops the preview and does not fire select. Release re-runs the same checks, so an option changed during the gesture applies to the final range.