Interaction

Drag & Drop

Drag an event to move it, pull its edge to resize it, drag across empty slots to create one, and drop items from anywhere on the page. Each gesture ends in one mutation, so saving is the same function whatever the gesture.

Move and Resize

Every gesture goes through onMutate and is logged below. The board meeting today is locked with editable: false, so it has no grab cursor and does not move. Switch the two props off to feel the difference.

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
All day
Lunch break

onMutate calls

  • Move, resize, create or delete something.
<!-- Nothing to switch on. Drag the body of an event to move it, drag its edge to
     resize it. Every gesture ends in one mutation. -->
<Scheduler bind:events onMutate={(mutation) => api.save(mutation)} />

What a Drop Does

The result depends on what was picked up and where it lands. Times snap to slotMinutes. A month cell keeps a timed event timed; the all-day row of the week view turns it all-day. Resizing has a floor of one slot or one day, and a bar cannot be resized at the edge where it continues into another row.

Drops

Picked upDropped onResult
Timed eventTime slotKeeps its length; start snaps to the slot
Timed eventMonth cellChanges day, keeps its clock time
Timed eventAll-day rowBecomes all-day, covering the days it spanned
All-day eventTime slotBecomes timed, two slots long
All-day eventWhole-day cellShifts by whole days
Edge of a timed eventSlotNew start or end, at least one slot
Edge of a timed eventDay cellThat edge changes day and keeps its time
Edge of a barDay cellNew first or last day, at least one day
// What a drop does, by where the event came from and where it lands
//
//   timed event   -> time slot        keeps its length, start snaps to the slot        (move)
//   timed event   -> month cell       changes day, keeps its clock time                 (move)
//   timed event   -> all-day row      becomes all-day, covering the days it spanned     (move)
//   all-day event -> time slot        becomes timed, two slots long                     (move)
//   all-day event -> whole-day cell   shifts by whole days                              (move)
//
//   edge of a timed event -> slot     new start or end, at least one slot               (resize)
//   edge of a timed event -> day cell changes the day of that edge, keeps its time      (resize)
//   edge of a bar         -> day cell new first or last day, at least one day           (resize)

Creating by Dragging

Drag across empty slots, or across days in the month view, and an event called labels.newEvent is created where you let go. A single click never creates anything; it reports the slot through onSelectSlot. When a createPanel is set the same drag opens your form instead; see Creating & Editing.

<!-- Drag across empty slots and an event named labels.newEvent is created, as a
     'create' mutation. A plain click creates nothing; it reports the slot. -->
<Scheduler
  bind:events
  onMutate={(mutation) => mutation.kind === 'create' ? api.create(mutation.after) : api.save(mutation)}
  onSelectSlot={(slot) => console.log('clicked', slot.start.toString())}
/>

<!-- With a createPanel the same drag opens your form instead: see Creating & Editing -->

Snapping and Autoscroll

A drag needs three pixels of movement to start, so a click stays a click. Times snap to the nearest slot boundary. When the pointer nears the top or bottom edge of a scrolling grid, the grid scrolls on its own, faster the closer to the edge. Escape cancels the gesture and puts the event back.

Touch

On a touch screen a drag starts after a press of 300 milliseconds, so a swipe scrolls the page and a rest picks up the event. While the press is held, the context menu is suppressed. A quick tap does what a click does.

Turning It Off

Two props and two event fields. Locked events lose their grab and resize cursors and the delete button in their details.

Locks

SettingTurns off
creatable={false}Drag to create, Enter to create
editable={false}Move, resize, delete and update of every event
editable: false on an eventMove, resize, delete and update of that event
background: trueEverything, always
An occurrence of a seriesEverything, always
<!-- No creating by drag or Enter. Drops from dragSource still create. -->
<Scheduler bind:events creatable={false} />

<!-- No moving, resizing, deleting or updating, for every event -->
<Scheduler bind:events editable={false} />

<!-- One event locked; the rest as usual -->
{ id: 'board', title: 'Board meeting', start: '...', end: '...', editable: false }

<!-- Always locked: background events, and occurrences of a series -->

Drop From Your Own List

dragSource is an attachment that turns any element into something that can be dropped on a grid. It takes a function returning the event's data, called when the drag starts. Drop on a time slot for a timed event of durationMinutes; drop on a month cell or the all-day row for an all-day event. The cards below are plain buttons.

Backlog 4 tasks

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
No events
<script lang="ts">
  import { dragSource, type DragSourceData } from '@sv5ui/scheduler';

  const tasks: DragSourceData[] = [
    { title: 'Write release notes', durationMinutes: 60, calendarId: 'work' },
    { title: 'Review pull requests', durationMinutes: 90, calendarId: 'team' },
    { title: 'Renew passport', calendarId: 'personal' }   // no length: two slots
  ];
</script>

<!-- Any element. The attachment reads the data when the drag starts. -->
{#each tasks as task (task.title)}
  <button {@attach dragSource(() => task)}>{task.title}</button>
{/each}

<Scheduler bind:events onMutate={save} />

DragSourceData

FieldDescription
title, calendarId, color, data, allDay, editable, backgroundCopied onto the created event
idOptional. Generated when omitted; the server may still replace it
durationMinutesLength on a time slot. Two slots when omitted. Ignored on a whole-day cell
// Everything an event has except its dates and its rule, plus a length
type DragSourceData<T> = Omit<EventInput<T>, 'id' | 'start' | 'end' | 'recurrence'> & {
  id?: string;               // generated when omitted; onMutate may still replace it
  durationMinutes?: number;  // length when dropped on a time slot; two slots when omitted
};

// Dropped on a time slot: a timed event of durationMinutes, start snapped to the slot.
// Dropped on a month cell or the all-day row: an all-day event of one day.
// Either way a 'create' mutation with before: null.

One List, Several Schedulers

A drag source belongs to the page, not to a scheduler. Drop the same card on either calendar below.

Linh

October 20, 2026

Tuesday

October 20, 2026

Today GMT+7

Minh

October 20, 2026

Tuesday

October 20, 2026

Today GMT+7
No events
<!-- A drag source is not tied to a scheduler. Drop it on any grid on the page. -->
<button {@attach dragSource(() => ({ title: 'Handover', durationMinutes: 30 }))}>Handover</button>

<Scheduler bind:events={linh} />
<Scheduler bind:events={minh} />

Refusing a Drop

There is no prop that forbids a slot. Decide in onMutate, where the whole event after the change is in hand: throw, and the event slides back while onError tells the user why. Below, anything dropped outside 9:00 to 18:00 is refused. An interaction plugin can refuse during the drag instead, when the feedback has to be immediate.

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
Lunch break
<!-- There is no rule prop. Refuse in onMutate: the event slides back and onError
     runs. The check sees the whole event after the change, so overlaps, hours
     and permissions are all one kind of check. -->
<Scheduler
  bind:events
  onMutate={(mutation) => {
    const event = mutation.after;
    if (!event) return api.remove(mutation.eventId);
    if (event.start.hour < 9 || event.end.hour > 18) throw new Error('Outside opening hours');
    if (overlapsAnother(event, events)) throw new Error('That slot is taken');
    return api.save(mutation);
  }}
  onError={(mutation, error) => toast.error(String(error))}
/>

The Cursor and the DOM

An event that can be moved shows a grab cursor, and its resize handles show a resize cursor through data-sch-edge. While a gesture runs the grid carries data-sch-gesture, and a drag source element carries data-sch-dragging until it is dropped, which is how the cards above fade.

<!-- The grid sets data-sch-gesture while a gesture runs, the event box hovers a
     data-sch-edge over its resize handles, and the source element carries
     data-sch-dragging while it is being dragged. -->
<button
  class="rounded border px-3 py-2 data-[sch-dragging]:opacity-50"
  {@attach dragSource(() => task)}
>
  {task.title}
</button>

<style>
  .planner :global([data-sch-gesture='move'] [data-sch-day-index]) { outline: 1px dashed var(--color-primary); }
</style>

Props

The props that govern gestures.

PropTypeDefault
creatablebooleantrue
editablebooleantrue
dragSourcesDragSourceData<T>[][]
onMutate(m: Mutation<T>) => MutationResult<T> | Promise<...>-
slotMinutesnumber30
onSelectSlot(selection: SlotSelection) => void-