# Drag, resize and business rules

> Validate moves while the user drags, prevent overlaps, block closed time, lock events, confirm changes asynchronously and explain every refusal with the drag card.

Source: https://superscheduler.org/en/docs/drag-resize-rules/
Reviewed: 2026-10-07

Steer every drag frame in onEventMoving and onEventResizing: set args.allowed = false and args.message to refuse a position with an explanation. Prevent overlaps with allowEventOverlap={false} (or per frame with args.allowOverlap), block time with disabled cells, lock single events with moveDisabled and resizeDisabled, and take the final decision in onEventMove or onEventResize, asynchronously if needed with args.async = true and args.loaded().

Dragging is where a planning board earns its keep, and where most business rules live: this job needs a lift, that stay cannot move into the past, the theatre is closed at lunch. SuperScheduler Pro asks your code at two moments. **While the user drags**, on every change of the shadow, you can accept, refuse or adjust the position and say why. **On drop**, once, you can cancel, alter or confirm the change, also after a round trip to your server.

This guide builds those rules on a workshop planning with service bays, then covers overlap, closed time, locks, asynchronous confirmation, the drag card and creating events by selecting a range. Everything here requires Pro; Lite is read-only.

## How a drag is decided
1. The user grabs an event. Locked events (`moveDisabled`) do not start a drag.
2. On every pointer move that changes the target time or row, **`onEventMoving`** runs (`onEventResizing` for a resize). Your rule sets `args.allowed`, may adjust `args.start` and `args.end`, and sets `args.message`.
3. The library then applies its own checks: overlap with other events when `allowEventOverlap` is `false`, and disabled cells. A refused shadow is drawn as forbidden and the drag card shows the reason.
4. On release over a refused position, nothing happens: the event goes back and no further callback runs.
5. On release over an accepted position, **`onEventMove`** (`onEventResize`) runs once, before the store changes. It can cancel, alter or defer the commit.
6. The store is updated, **`onEventMoved`** (`onEventResized`) runs, and `onEventsChange` follows on the next microtask, as described in [Controlled events and callbacks](https://superscheduler.org/en/docs/controlled-state/#callback-order).

The same commit sequence runs for moves and resizes made with the keyboard in `keyboardMode: 'Full'`.

## Validate while dragging
`onEventMoving` receives the candidate position and writes the decision back into its arguments:

| Writable | Effect |
|---|---|
| `allowed` | `false` draws the shadow as forbidden; dropping there does nothing |
| `message` | Text the drag card shows while `allowed` is `false` |
| `start`, `end` | Adjust the shadow, for example to keep the original times when only the row changes |
| `allowOverlap` | Overrides `allowEventOverlap` for this frame only |
| `cssClass`, `html` | Class and content of the shadow |

It also reads the context: `args.e` (the dragged event, with your data in `args.e.data`), `args.resource` and `args.row` (the target row), `args.duration`, `args.conflicts`, `args.external` (dragged from outside the scheduler) and the modifier keys. `onEventResizing` works the same way on `start`, `end`, `allowed`, `message` and `allowOverlap`, and adds `args.what`, the edge being dragged (`'start'` or `'end'`).

```tsx
// src/WorkshopPlanning.tsx
import { useMemo } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SchedulerProps } from 'super-scheduler'

const BAYS: SuperScheduler.ResourceData[] = [
  { id: 'bay-1', name: 'Bay 1 (lift)' },
  { id: 'bay-2', name: 'Bay 2 (lift)' },
  { id: 'bay-3', name: 'Bay 3' },
  { id: 'waiting', name: 'Waiting list' },
]
const BAYS_WITH_LIFT: ReadonlySet<SuperScheduler.ResourceId> = new Set(['bay-1', 'bay-2'])

const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Day', format: 'dddd d MMMM' },
  { groupBy: 'Hour', format: 'HH:mm' },
]

/** A custom field of the job data (see "Custom fields" in the data model guide). */
function needsLift(data: SuperScheduler.EventData): boolean {
  return 'needsLift' in data && data.needsLift === true
}

interface Props {
  readonly jobs: SuperScheduler.EventData[]
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
}

export function WorkshopPlanning({ jobs, onEventsChange }: Props) {
  const config = useMemo<SchedulerProps>(
    () => ({
      startDate: '2026-10-12',
      days: 5,
      scale: 'CellDuration',
      cellDuration: 30,
      cellWidth: 48,
      timeHeaders: TIME_HEADERS,
      businessBeginsHour: 8,
      businessEndsHour: 18,
      showNonBusiness: false,
      useEventBoxes: 'Never',
      allowEventOverlap: false,
      conflictHighlight: true,

      // Runs on every shadow change: keep it synchronous and cheap.
      onEventMoving: (args) => {
        if (args.start.getTime() < SuperScheduler.Date.now().getTime()) {
          args.allowed = false
          args.message = 'Jobs cannot be moved into the past.'
          return
        }
        if (needsLift(args.e.data) && !BAYS_WITH_LIFT.has(args.resource)) {
          args.allowed = false
          args.message = 'This job needs a bay with a lift.'
          return
        }
        // The waiting list may hold overlapping jobs; the bays may not (allowEventOverlap above).
        args.allowOverlap = args.resource === 'waiting'
      },

      onEventResizing: (args) => {
        const minutes = (args.end.getTime() - args.start.getTime()) / 60_000
        if (minutes < 30) {
          args.allowed = false
          args.message = 'A job takes at least 30 minutes.'
        }
      },
    }),
    [],
  )

  const owned = useMemo(() => jobs.slice(), [jobs])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={BAYS}
      events={owned}
      onEventsChange={onEventsChange}
    />
  )
}
```
You should see a five-day planning in half-hour cells from 08:00 to 18:00. Drag a job that needs a lift onto Bay 3: the shadow turns forbidden and the card reads "This job needs a bay with a lift.". Drag any job over another one on a bay: it is refused as an overlap. Drop it on the waiting list: both jobs stack there. Shorten a job below 30 minutes: the resize is refused.

> **Tip:**
> These handlers run on every frame of a drag, so keep them synchronous and cheap: look up precomputed sets and maps, never call a server. Put slow checks in `onEventMove`, which runs once.

## Prevent overlaps
`allowEventOverlap={false}` refuses any move, resize or range selection that would overlap another event in the same row. Intervals are half-open, so back-to-back events (one ends at 11:00, the next starts at 11:00) never count as an overlap.

Two tools refine the rule per situation:

- **`args.allowOverlap`** in `onEventMoving` and `onEventResizing` overrides the option for the current frame, as the waiting list above does. It resets on every call.
- **`args.conflicts`** lists the existing events the shadow collides with in the target row (up to eight), as `SuperScheduler.Event` wrappers. Use it to tell soft conflicts from hard ones:

```ts
// src/soft-conflicts.ts
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'

function isTentative(data: SuperScheduler.EventData): boolean {
  return 'status' in data && data.status === 'tentative'
}

/** Tentative jobs may be double-booked; confirmed ones may not. */
export const softConflicts: Pick<
  SchedulerProps,
  'allowEventOverlap' | 'conflictHighlight' | 'onEventMoving'
> = {
  allowEventOverlap: false,
  // Outlines the events the shadow collides with (data-conflict) while dragging.
  conflictHighlight: true,
  onEventMoving: (args) => {
    // Up to eight colliding events in the target row: feedback, not exhaustive validation.
    const hard = args.conflicts.find((event) => !isTentative(event.data))
    if (hard === undefined) {
      // For this frame only; the instance option stays false.
      args.allowOverlap = true
      return
    }
    args.allowed = false
    args.message = `Overlaps ${hard.text()}, which is confirmed.`
  },
}
```
`conflictHighlight` outlines the colliding events while the user drags (they get a `data-conflict` attribute and a danger-colored outline), so the user sees what is in the way, not only that something is.

> **Limitation:**
> `args.conflicts` is a bounded sample for feedback, not a complete validation: with many collisions it lists at most eight. Overlap rules that must hold for every event belong on your server as well.

## Block time with disabled cells
A disabled cell is drawn hatched and rejects moves, resizes and range selections that touch it. There are two ways to disable cells:

- **A whole row:** `cellsDisabled: true` on the resource, for a bay under repair or a room out of order.
- **Any cell:** set `args.cell.properties.disabled = true` in `onBeforeCellRender`, for lunch breaks, holidays or per-resource opening hours.

```tsx
// src/ClosedTime.tsx
import { useMemo } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerBeforeCellRenderArgs, SuperScheduler } from 'super-scheduler'

const BAYS: SuperScheduler.ResourceData[] = [
  { id: 'bay-1', name: 'Bay 1' },
  { id: 'bay-2', name: 'Bay 2' },
  // Closed for refurbishment: every cell of the row is disabled.
  { id: 'bay-3', name: 'Bay 3', cellsDisabled: true },
]

/**
 * Module level, so its identity never changes: a new function per render would invalidate the
 * per-cell cache. Disabled cells are hatched and reject drops, resizes and range selection.
 */
function closeLunchBreak(args: SchedulerBeforeCellRenderArgs): void {
  if (args.cell.start.getHours() === 13) {
    args.cell.properties.disabled = true
    args.cell.properties.cssClass = 'lunch-break'
  }
}

export function ClosedTime({ jobs }: { jobs: SuperScheduler.EventData[] }) {
  const owned = useMemo(() => jobs.slice(), [jobs])
  return (
    <SuperSchedulerComponent
      startDate="2026-10-12"
      days={5}
      scale="CellDuration"
      cellDuration={30}
      resources={BAYS}
      events={owned}
      onBeforeCellRender={closeLunchBreak}
    />
  )
}
```
You should see Bay 3 hatched along its whole row and every bay hatched from 13:00 to 14:00. Jobs cannot be dropped or stretched across the lunch break.

> **Behavior:**
> `onBeforeCellRender` results are cached per cell. If the result depends on events (for example "full" cells), set `cellsAutoUpdated: true` on the resource so its cells are recomputed when its events change, or call `control.update()` after the change. Keep the handler's identity stable, as above, or every render drops the cache.

Other ways to express closed time: hide it from the axis with `showNonBusiness={false}` or `onIncludeTimeCell` (see [Hours, minutes, days and zoom](https://superscheduler.org/en/docs/time-scales-zoom/#business-hours)), or represent it as events that cannot move or resize (`moveDisabled`, `resizeDisabled`), such as a maintenance block, which also count as overlaps when `allowEventOverlap` is `false`.

## Lock individual events
| Event field | Effect |
|---|---|
| `moveDisabled` | The event cannot be moved |
| `resizeDisabled` | The event cannot be resized |
| `moveHDisabled` | It can change row but not time |
| `moveVDisabled` | It can change time but not row |
| `clickDisabled`, `deleteDisabled` | It ignores clicks, or has no delete button |

For the whole scheduler, `eventMoveHandling="Disabled"` and `eventResizeHandling="Disabled"` turn the gestures off. Rules that depend on who is looking (a receptionist may move, a guest may not) are permissions: compute these fields from the user's role before you pass the events.

## Confirm on drop, also asynchronously
`onEventMove` and `onEventResize` run once per drop, before anything changes. They can:

- **Cancel** with `args.preventDefault()`.
- **Alter** the result by assigning `args.newStart`, `args.newEnd` or `args.newResource`.
- **Defer** with `args.async = true`, then call `args.loaded()` when you have an answer. Calling `args.preventDefault()` before `loaded()` cancels the drop.

```ts
// src/confirm-move.ts
import type {
  SchedulerEventMoveArgs,
  SchedulerEventResizeArgs,
  SuperScheduler,
} from 'super-scheduler'

function inProgress(data: SuperScheduler.EventData): boolean {
  return 'status' in data && data.status === 'inProgress'
}

/**
 * onEventMove: called once on drop, before the store changes. The library never awaits a
 * handler, so an asynchronous decision defers the drop with `async` and finishes it with `loaded()`.
 */
export function confirmMove(args: SchedulerEventMoveArgs): void {
  // A synchronous veto needs no async: cancel and return. This final check also covers moves
  // made with the keyboard.
  if (inProgress(args.e.data) && args.newResource !== args.e.resource()) {
    args.preventDefault()
    args.control.message('A job in progress stays in its bay.')
    return
  }

  args.async = true
  const resource = String(args.newResource)
  void (async () => {
    try {
      const question = `Move ${args.e.text()} to ${resource}, ${args.newStart.toString('ddd d MMM HH:mm')}?`
      if (!(await confirmWithUser(question))) {
        args.preventDefault()
        return
      }
      await saveBooking({
        id: String(args.e.id()),
        resource,
        start: args.newStart.value,
        end: args.newEnd.value,
      })
    } catch {
      args.preventDefault()
      args.control.message('The move could not be saved.')
    } finally {
      // Always: completes the drop, or cancels it when preventDefault() was called first.
      args.loaded()
    }
  })()
}

/** The same protocol for resizing; `what` tells which edge moved. */
export function confirmResize(args: SchedulerEventResizeArgs): void {
  args.async = true
  void saveBooking({
    id: String(args.e.id()),
    resource: String(args.e.resource()),
    start: args.newStart.value,
    end: args.newEnd.value,
  })
    .catch(() => args.preventDefault())
    .finally(() => args.loaded())
}
```
Attach them as `onEventMove={confirmMove}` and `onEventResize={confirmResize}`. While the decision is pending, the event stays at its original position; it moves when `loaded()` completes the drop, or stays put if the drop was cancelled.

> **Behavior:**
> Handlers are typed as returning `void`, and the library never awaits them. An `async` handler type-checks, but the drop commits as soon as it returns its promise. For asynchronous decisions, set `args.async = true` synchronously and call `args.loaded()` exactly once, in a `finally` block, so a failed request can never leave a drop pending.

## The drag card
While dragging, a card next to the pointer shows the target dates, the duration (nights for whole-day ranges, hours and minutes otherwise), the target row, and why a position is refused: your `args.message`, or the event it overlaps. A date marker also appears in the time header (`headerMarker`). Both are on by default.

Configure the card with one stable object:

```ts
// src/drag-card.ts
import { SuperScheduler } from 'super-scheduler'

// One stable object at module level: a new object per render would reconfigure the card.
export const DRAG_CARD: SuperScheduler.DragCardOptions = {
  // Intraday work: show the time of both edges.
  dateFormat: 'ddd d MMM HH:mm',
  movingDateFormat: 'ddd d MMM HH:mm',
  // Whole-day ranges count nights by default; other ranges show hours and minutes.
  duration: 'auto',
  row: true,
  labels: { overlapping: 'Overlaps', forbidden: 'Not allowed' },
}

// Replace the content when a refusal needs more room. The string is trusted HTML.
export const DRAG_CARD_WITH_REASON: SuperScheduler.DragCardOptions = {
  ...DRAG_CARD,
  html: (info) => {
    if (info.refusal === null) return null // null keeps the default content
    const reason = SuperScheduler.Util.escapeHtml(info.refusal)
    const row = SuperScheduler.Util.escapeHtml(info.rowName ?? '')
    return `<strong>${reason}</strong><br>${row}`
  },
}
```
Pass `dragCard={DRAG_CARD}`, or `dragCard={false}` to remove it. The default labels are translated for English, Spanish, Catalan, Basque, Galician, German, French, Italian and Portuguese, following the scheduler's `locale`; `labels` overrides them. The `html` option receives the dates, the row name, the first conflict, the refusal message and the event's position before the drag.

## Create events by selecting time
Dragging across empty cells selects a time range; `onTimeRangeSelected` reports it with `start`, `end` (exclusive), `resource` and `origin`. Creating an event there is your application's decision:

```tsx
// src/CreateOnSelect.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerEventsChangeArgs,
  SchedulerTimeRangeSelectedArgs,
  SchedulerTimeRangeSelectingArgs,
  SuperScheduler,
} from 'super-scheduler'

const BAYS: SuperScheduler.ResourceData[] = [
  { id: 'bay-1', name: 'Bay 1' },
  { id: 'bay-2', name: 'Bay 2' },
]

/** Steers the selection while it is drawn, as onEventMoving does for moves: four hours at most. */
function limitToFourHours(args: SchedulerTimeRangeSelectingArgs): void {
  args.allowed = args.end.getTime() - args.start.getTime() <= 4 * 3_600_000
}

export function CreateOnSelect() {
  const [jobs, setJobs] = useState<SuperScheduler.EventData[]>([])
  const owned = useMemo(() => jobs.slice(), [jobs])
  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setJobs([...args.events]),
    [],
  )

  const onTimeRangeSelected = useCallback((args: SchedulerTimeRangeSelectedArgs) => {
    // The selection shadow stays until cleared.
    args.control.clearSelection()
    // A plain click on an empty cell also selects it (origin 'click'): create only on a drag.
    if (args.origin !== 'drag') return
    setJobs((current) => [
      ...current,
      {
        id: crypto.randomUUID(),
        resource: args.resource,
        // Store strings: start and end arrive as SuperScheduler.Date (end exclusive).
        start: args.start.value,
        end: args.end.value,
        text: 'New job',
      },
    ])
  }, [])

  return (
    <SuperSchedulerComponent
      startDate="2026-10-12"
      days={5}
      scale="CellDuration"
      cellDuration={30}
      resources={BAYS}
      events={owned}
      allowEventOverlap={false}
      onTimeRangeSelecting={limitToFourHours}
      onTimeRangeSelected={onTimeRangeSelected}
      onEventsChange={onEventsChange}
    />
  )
}
```
You should see a selection shadow follow the pointer, refuse to grow beyond four hours or over another job, and become a "New job" event on release.

Three behaviors to know:

- **A plain click is a selection too.** Clicking an empty cell fires `onTimeRangeSelected` with `origin: 'click'` and one cell. Check `origin === 'drag'` if a click must not create anything.
- **The selection stays visible** after release until the next selection or `args.control.clearSelection()`.
- **Selections follow the same rules as moves:** they cannot cross disabled cells, nor occupied time when `allowEventOverlap` is `false`. `onTimeRangeSelecting` steers them frame by frame with `args.allowed`.

## What stays in your application
The library enforces what you configure and reports what happens. Your application owns the rules themselves (which resource accepts which work, who may change what), the server-side validation of every change, and any automatic placement or optimization. Moving a booking never reschedules the others: if your business needs cascading changes, compute them and update the events yourself.

→ https://superscheduler.org/en/examples/clinic-appointments/
→ https://superscheduler.org/en/examples/manufacturing-orders/
→ https://superscheduler.org/en/examples/sports-club-courts/
## Next steps
- Persist the changes these rules accept: [Controlled events and callbacks](https://superscheduler.org/en/docs/controlled-state/#persistence).
- Let users undo a move: [Undo and redo](https://superscheduler.org/en/docs/undo-redo/).
- Drive the same rules from the keyboard: [Keyboard, accessibility and touch](https://superscheduler.org/en/docs/keyboard-accessibility-touch/).
