Docs/Examples
Week calendar
A week planner — events moved across days and quarters of an hour, stretched by their edges, drawn on empty time, made all day, planned from a task list, with undo.
Loading the demo…
<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Announcements } from '@vue-dnd-kit/core';
import Calendar from './Calendar.vue';
import { CALENDAR, createCalendar } from './calendar';
import { spokenOf, type DragData } from './placement';
import { toDay } from './time';
// The calendar's state, shared by every component under it.
provide(CALENDAR, createCalendar(toDay(new Date())));
/** The event a drag carries; edges and empty time carry none. */
const eventOf = (data: unknown) => ((data as DragData).kind === 'event' ? (data as Extract<DragData, { kind: 'event' }>).event : null);
// Screen readers hear titles and times, not ids. Where a keyboard drag would land is spoken by
// the week as the arrows move it, and what a drop changed by the toast: the live region
// stays quiet for both.
const announcements: Partial<Announcements> = {
onDragStart: ({ active }) => {
const event = eventOf(active.data);
if (!event) return '';
const keys =
active.sensor === 'keyboard'
? ' Arrow keys move it by a quarter of an hour and by a day, with Shift by an hour. Space drops it, Escape cancels.'
: '';
return `Picked up ${event.title}, ${spokenOf(event, event.duration)}.${keys}`;
},
onDragOver: () => '',
onDragEnd: () => '',
onDragCancel: ({ active }) => {
const event = eventOf(active.data);
return event ? `${event.title} stays where it was.` : '';
},
};
</script>
<template>
<DnDProvider :announcements="announcements">
<Calendar />
</DnDProvider>
</template>What to look at
EventBlock.vue: an event in the hours is a plain draggable whose overlay,EventOverlay.vue, says where it would land: "Wed 10:30 – 11:15". Overlapping events go side by side (layoutDayinplacement.ts).Edge.vue: the top and bottom edges of an event are draggables of their own. Drag one and the event stretches, a quarter of an hour at a time.DayColumn.vue: each day's hours are a drop target, and under its events lies a draggable too: press empty time and drag to draw a new event, then type its title.AllDayCell.vueandTasks.vue: drop an event on a day's all-day cell and it loses its time; drop it on the task list and it loses its day. Tasks (EventChip.vue) drag into the week, and keep their length.Week.vue: the drag area of the hours and their own modifier, the keyboard moves, the line of the current time.calendar.ts: the events and an undo history. Every change goes through one function, and the toast offers to take it back.
Expand the example: with room for it, a side panel shows the month, the calendars to show or hide, and the tasks as a list. On a phone the week becomes three days, and a long press drags.
One answer for every drop
What a drop would do is asked in four places: the overlay's label, the ghost a task shows in the
day under it, what screen readers hear, and the drop itself. They all call placeOf, so they
never disagree. Each drop target carries what it is in its data, and position is where the
dragged box is inside it: its top is the time.
export function placeOf(event: CalendarEvent, over: Over | null, position: Coordinates | null): Place | null {
const target = over?.accepted ? (over.data as Target) : null;
if (!target) return null;
if (target.kind === 'tasks') return { day: null, start: null };
if (target.kind === 'all-day') return { day: target.day, start: null };
if (!position) return null;
return { day: target.day, start: clamp(minutesAt(position.y), 0, DAY_MINUTES - event.duration) };
}
The overlay reads it live, from useDnD().position, and keeps its last answer while it lands:
const { position } = useDnD();
const place = shallowRef(placeOf(event.value, props.over, position.value));
watchEffect(() => {
if (props.status === 'dragging') place.value = placeOf(event.value, props.over, position.value);
});
An event keeps its id, its data and its overlay wherever it is: a block in the hours, a chip all
day or among the tasks. Dropped somewhere else, it is rendered by another component, and the
overlay lands on the new element all the same.
The hours' own modifier
makeDragArea gives the drags that start in the hours (an event, an edge, empty time) a modifier
of their own. Over the days, the box moves by whole days, as many as the pointer crossed, so an
event beside another keeps its place in the day; its top snaps to the quarters of an hour of the
day it started in. containerRect is that day where it is now: it follows the scroll, and the
snapping holds while the hours auto-scroll under a drag.
const snapToWeek: Modifier = ({ transform, overlayRect, containerRect, origin }) => {
const days = grid.value?.getBoundingClientRect();
if (!days || !containerRect) return transform;
const width = days.width / props.days.length;
const column = (x: number) => Math.floor((x - days.left) / width);
const pointer = origin.x + transform.x;
const x = pointer < days.left || pointer >= days.right ? transform.x : (column(pointer) - column(origin.x)) * width;
const top = overlayRect.top - containerRect.top;
return { x, y: transform.y + Math.round(top / STEP_PX) * STEP_PX - top };
};
makeDragArea(grid, { modifiers: [snapToWeek], autoScroll: { threshold: 0.12 } });
The area's autoScroll narrows the edge that scrolls the hours, for every drag over them: the
all-day row right above stays easy to reach. Tasks and all-day events start outside the area and
move freely; the day under them shows a ghost where they would land.
Stretching: an edge is a draggable
An edge lies inside its event's element, and a press drags the closest draggable: the edge. It has no overlay, moves up and down only, and stays in the hours in view.
makeDraggable(el, {
id: () => `${props.event.id}-${props.edge}`,
type: 'edge',
data: (): DragData => ({ kind: 'edge', event: props.event, edge: props.edge }),
overlay: false,
axis: 'y',
modifiers: [restrictToFirstScrollableAncestor],
attributes: false, // no tab stop: Shift + arrows change the end of a focused event
});
The edge itself doesn't move. The event grows instead, from where the edge is in the day, read
from position as the drop will read it:
const time = computed(() =>
resizing.value && position.value
? resizeTo(props.event, resizing.value, position.value)
: { start: props.event.start!, duration: props.event.duration },
);
Drawing on empty time
Under the events, each day has a lane, and the lane is a draggable without an overlay. It never
moves: how far its box went (position) is how far the pointer went from the press, snapped to
quarters of an hour by the area. The day shows the new event as it is drawn, and its drop creates
it.
makeDraggable(lane, {
id: () => `new-${props.day}`,
type: 'new',
data: (): DragData => ({ kind: 'new', day: props.day, from: from.value }),
overlay: false,
axis: 'y',
attributes: false, // the New event button is the keyboard's way
});
The day's accept is a function: it takes any event, and only its own edges and lanes. The
all-day cells and the task list take 'event', a string, so the edge and new types are
refused there without a line of code. The drop is one handler:
onDrop: ({ active, over, position }) => {
const data = active.data as DragData;
if (!position) return;
if (data.kind === 'event') {
const place = placeOf(data.event, over, position);
if (place) calendar.place(data.event.id, place);
} else if (data.kind === 'edge') {
calendar.resize(data.event.id, resizeTo(data.event, data.edge, position));
} else {
const { start, duration } = rangeOf(data.from, data.from + position.y);
calendar.create(props.day, start, duration);
}
},
The keyboard
Tab to an event, pick it up with Space: the week's monitor answers the arrows,
getKeyboardCoordinates, with the next quarter of an hour (with Shift, an hour) or the next day.
Keyboard drags don't auto-scroll, so near an end of the hours in view the arrow scrolls them
instead. Tasks and all-day events jump from target to target (keyboardMode: 'droppables') until they
are over the hours.
useDnDMonitor({
getKeyboardCoordinates: (key, { pointer, direction }) => {
// …only over the hours
const dy = direction.y * STEP_PX * (key.shiftKey ? 4 : 1);
const next = { x: pointer.x + direction.x * (grid.value.offsetWidth / props.days.length), y: pointer.y + dy };
if (canScroll && (next.y < view.top + HOUR_PX || next.y > view.bottom - HOUR_PX)) {
hours.scrollTop += dy;
return { x: next.x, y: pointer.y };
}
return next;
},
});
The provider's announcements in index.vue say what was picked up and how to move it; the week
speaks the time as the arrows change it ("Tuesday, 10:30 to 11:15"), and the toast what a drop
changed. On a focused event, Delete removes it, and Ctrl+Z undoes any change.
Read more: Your own modifier · Rules for a part of the page · Leave it where it was dropped · Without an overlay · Overlay components · Accessibility