Docs/Guides
Sensors and programmatic drags
How input becomes a drag — the pointer and keyboard sensors, activation constraints, canDrag, sensors of your own, and drags you script with @vue-dnd-kit/actions.
A sensor turns input into drags. It only tells the provider "drag this item from here", then where the input moved, and whether it dropped or cancelled. The provider does the rest, the same way for every sensor: one drag at a time, disabled items refused, activation constraints, sorting, auto-scroll, announcements, Escape and the whole drop.
The built-in sensors
| Sensor | Input |
|---|---|
pointerSensor | Mouse, touch and pen, through Pointer Events. It captures the pointer, keeps the page from selecting text or scrolling under a touch drag, and swallows the click that follows a drag. |
keyboardSensor | Space and Enter pick up and drop, arrows move, Escape cancels — any drag, pointer drags too. |
Both run by default. The provider's sensors replaces the list:
<DnDProvider :sensors="[pointerSensor, keyboardSensor, joystick.sensor]">
To turn keyboard drags off, prefer :keyboard="false" to leaving keyboardSensor out: the
keyboard sensor is also what cancels any drag with Escape. Its keys are rebound with
keyboard.keys (see Accessibility).
Activation constraints
A press doesn't drag at once: it waits for its activation constraint, so a click stays a click and a swipe can still scroll the page. Switch the rules of this list and press a row:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Inbox from './Inbox.vue';
</script>
<template>
<DnDProvider>
<Inbox />
</DnDProvider>
</template>A constraint has three fields:
| Field | The drag starts… |
|---|---|
distance | Once the pointer has moved that many px. |
delay | Once the press has lasted that many ms. With distance too, whichever comes first. |
tolerance | With delay: how far the pointer may move meanwhile. Past it the press gives up, so the page can scroll. Default 5 px when there is no distance. |
Without your own, each pointer kind has its default:
| Pointer | Default |
|---|---|
| Mouse | After moving 4 px. At once when pressed on a handle, unless a mouse constraint is set. |
| Pen | After moving 4 px. |
| Touch | After a 200 ms press. Moving more than 5 px first gives up, so the page scrolls instead. |
Where to set them
Per pointer kind — mouse, touch, pen — on the provider, a drag area or the item. For each
kind, the item's own wins, then its area's, then the provider's:
<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 250, tolerance: 5 } }">
// Inbox.vue: the rules of every drag that starts inside the panel.
makeDragArea(panel, { activation: () => MODES[mode.value].activation });
// One item: a longer press by touch, the area's rule for the mouse.
makeSortableItem(el, { item: () => props.task, activation: { touch: { delay: 400, tolerance: 8 } } });
The options are read when a press starts, so a getter or a ref switches them live, as the demo does. Keyboard drags start at once. A sensor of your own asks for its own constraint, or for the one configured for a pointer kind.
A long-press ring
While a press waits, useDnD().status is 'activating' and activation.progress goes from 0 to
1 — by time with delay, by the distance moved with distance. The demo draws a ring where the
press started:
<script setup lang="ts">
const { status, activation } = useDnD();
</script>
<template>
<svg v-if="status === 'activating'" class="ring" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="17" pathLength="1" :stroke-dasharray="`${activation.progress.value} 1`" />
</svg>
</template>
Give the ring pointer-events: none: it sits under the pointer, and the drag hit-tests what is
there. A press that gives up — moved past its tolerance, or let go too early — takes status
straight back to 'idle'.
Handles start at once
With a handle, only the handle starts a drag, and a mouse press on it drags at once: grabbing a
grip leaves nothing to tell apart. A press released where it started is still a click, so the
grip can open a menu on click (the pointer is captured only once it moves). A touch still waits for its long press, unless the handle can't
scroll the page. Give it touch-action: none and the item a short distance:
makeSortableItem(el, {
item: () => props.chat,
handle: '.grip',
// The grip has `touch-action: none`: a touch on it can't scroll, so it drags after 4 px.
activation: { touch: { distance: 4 } },
});
A mouse constraint set on the item, its area or the provider applies to handles too. Make the
grip a <button>, as the demo does: it takes the focus, so a keyboard drag starts from it
too.
Only some presses
canDrag is one more gate on draggables and sortable items. It gets the input event and answers
true or false for this press. Here pointer drags need Shift; the keyboard drags
as usual:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Checklist from './Checklist.vue';
</script>
<template>
<DnDProvider>
<Checklist />
</DnDProvider>
</template>makeSortableItem(el, {
item: () => props.task,
// Never: the row is locked (and says so to screen readers with aria-disabled).
disabled: () => !!props.task.locked,
// Per press: pointer drags only with Shift held; the keyboard as usual.
canDrag: (event) => {
if (!props.shiftOnly || event instanceof KeyboardEvent || event.shiftKey) return true;
emit('refused');
return false;
},
});
| Option | Asked | Use it for |
|---|---|---|
disabled | Live, and set as aria-disabled | Items that can't move at all: locked, read-only. |
canDrag | Once per press, with its event | Rules about the input: a key held, a button, a pointer type. |
handle | Where the press lands | Items that start a drag from one part only. |
canDrag is asked before the activation constraint, with the pointerdown or keydown. A pointer
type is one more rule it can make:
// Mouse and pen drag; a touch on the row always scrolls the page.
canDrag: (event) => !(event instanceof PointerEvent && event.pointerType === 'touch'),
Interactive children — buttons, inputs, checkboxes, editable fields — never start a pointer drag,
so they keep working inside a draggable, as the checkbox and the owner menu in the demo do. With a
handle, only the handle starts one.
A sensor of your own
A gamepad, an on-screen joystick, voice commands: anything that can point at an item and move can drive a drag. This gamepad is a sensor. Hold A over a hero to pick it up, steer with the pad, A again drops, B cancels. The mouse and the keyboard still work next to it:
Loading the demo…
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue';
import { DnDProvider, keyboardSensor, pointerSensor } from '@vue-dnd-kit/core';
import Party from './Party.vue';
import Gamepad from './Gamepad.vue';
import { createJoystick } from './joystick';
const stage = useTemplateRef<HTMLElement>('stage');
const joystick = createJoystick(() => stage.value);
// The gamepad drives drags next to the mouse, touch and the keyboard.
const sensors = [pointerSensor, keyboardSensor, joystick.sensor];
onMounted(() => {
const first = stage.value?.querySelector('li');
if (first) joystick.aim(first);
});
</script>
<template>
<DnDProvider :sensors="sensors">
<div ref="stage" class="arcade">
<Party />
<Gamepad :joystick="joystick" />
<span class="cursor" :style="{ left: `${joystick.cursor.value.x}px`, top: `${joystick.cursor.value.y}px` }" />
</div>
</DnDProvider>
</template>
<style scoped>
.arcade {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 32px 48px;
padding: 8px;
}
/* The gamepad's cursor: above everything, never what a hit test finds. */
.cursor {
position: absolute;
z-index: 1;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border: 2px solid #42b883;
border-radius: 50%;
background: color-mix(in srgb, #42b883 25%, transparent);
box-shadow: 0 0 0 4px color-mix(in srgb, #42b883 15%, transparent);
pointer-events: none;
}
</style>defineSensor gives the sensor its name and a setup that receives the provider's side, once per
provider:
const sensor = defineSensor({
name: 'joystick',
// Once per provider, in the browser. The cleanup runs when the provider unmounts.
setup(ctx) {
context = ctx;
return () => {
context = null;
drag = null;
};
},
});
A press asks the provider for a drag with activate(). The target is checked like a real press:
it must be a draggable of this provider, not disabled, and not a button inside one:
/** A goes down: drops a running drag, or starts a hold over what is under the cursor. */
const press = () => {
if (drag?.state === 'dragging') return void drag.drop();
const at = point();
const target = document.elementFromPoint(at.x, at.y);
if (!target || !context) return;
drag = context.activate({
target, // checked like a real press: a draggable of this provider, not disabled
point: at,
type: 'pointer', // follows points: lists sort under it, containers auto-scroll
activation: { delay: 300 }, // `useDnD().activation.progress` runs meanwhile
onEnd: (ended) => {
if (drag === ended) drag = null;
},
}); // null: nothing to drag there
};
Then the drag moves with the pad, and A or B end it:
const move = (dx: number, dy: number) => {
cursor.value = { x: cursor.value.x + dx, y: cursor.value.y + dy }; // clamped in the demo
drag?.move(point()); // pending: checks the tolerance; dragging: moves
};
const release = () => {
if (drag?.state === 'pending') drag.cancel(); // A let go before the hold is over
};
const cancel = () => drag?.cancel();
Add the sensor to the provider's sensors, as the demo does, or run it for as long as a component
lives with useSensor(joystick.sensor). Points are in viewport coordinates: the demo keeps its
cursor in the stage's coordinates, so it scrolls with the page, and converts it in point().
What the sensor gets
setup(context) receives the provider's side:
| Member | What it is |
|---|---|
props | The provider's props (activation, keyboard…). Read them when needed: they may change. |
status | The provider's drag status, whichever sensor runs the drag. |
drag | The provider's current drag, pending or running, whichever sensor started it. null when there is none. |
activate(options) | Asks for a drag. Returns a SensorDrag, or null when no drag may start. |
cancel() | Cancels the provider's drag or pending press. |
activate() takes:
| Option | Meaning |
|---|---|
target | Where the input landed: an element inside a draggable, or a draggable's id. An element is checked like a real press (handle, no interactive child); an id skips the checks. |
point | Where the drag starts, in viewport coordinates. Default: the center of the draggable. |
type | 'pointer' (default): follows points, hit-tests targets, auto-scrolls. 'keyboard': moves by items, reveals targets, returns focus at the end. |
activation | A constraint ({ delay: 300 }), or a pointer kind ('touch') to use the configured one. Default: none, the drag starts at once. |
event | The input event, handed to the item's canDrag (without one, canDrag isn't asked). A touch pointerType makes it a touch drag (haptics). |
onStart(drag) | The drag started, after its activation. |
onEnd(drag) | The drag is over: aborted, dropped or cancelled — by anyone. |
onResume(drag) | onBeforeDrop answered 'continue': the drag goes on, and move, drop and cancel apply again. |
The SensorDrag it returns:
| Member | What it does |
|---|---|
id, type | The dragged item's id, and 'pointer' or 'keyboard'. |
state | 'pending' (waiting for the activation), 'dragging', then 'ended'. |
started | The drag started, even if it has ended since. |
move(point) | The input moved. While pending, checks the tolerance; while dragging, moves the drag. |
step(direction) | Keyboard drags: one arrow step, 'up' | 'down' | 'left' | 'right'. |
place({ list, index }) | Puts the item at a position of a sortable list, as keyboard moves do. Resolves false when no list takes it there. |
drop() | Drops. Resolves true when the drop went through, once it has landed. |
cancel() | Cancels: the item flies back, or a pending press is abandoned. |
Calls that no longer apply — a move after the end, a second drop() — are ignored. Full types:
Sensors.
setup with a context of your own. Make it with
Object.create(context, { activate: { value: myActivate } }) rather than a spread, so the live
getters (status, drag, props) keep working.Programmatic drags
@vue-dnd-kit/actions moves items from code: "Move up" buttons, menus, keyboard shortcuts, undo,
onboarding tours, tests. It is built on the public sensor API, and its drags are real ones:
announced and animated, through onBeforeDrop, onDrop, e.result and preventDefault(), under
the provider's rules (one drag at a time, disabled items, accept and group.put).
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Queue from './Queue.vue';
</script>
<template>
<!-- @vue-dnd-kit/actions moves the rows from code: buttons, a shuffle, a scripted drag. -->
<DnDProvider>
<Queue />
</DnDProvider>
</template>npm i @vue-dnd-kit/actions
moveItem: one call, one move
import { useDnDActions } from '@vue-dnd-kit/actions';
const { moveItem } = useDnDActions(); // in setup, under a provider
await moveItem('card-1', { index: 0 }); // to the top of its list
await moveItem('card-1', { list: 'done' }); // to the end of another list
await moveItem('card-1', { list: 'done', index: 2 }); // to a place in another list
listis a sortable list'sid. Without it, the item stays in its list.indexis where the item ends up, counted without the item itself, and clamped to the list. Without it, the item goes to the end.- It resolves
truewhen the drop went through,falsewhen the item isn't rendered or is disabled, no list takes it there, or the drop was refused. - Calls queue: each waits until the provider is idle, so a quick second click isn't lost. The shuffle in the demo is five calls in a row.
- It runs as a keyboard drag, so it is announced and the focus stays on the item.
- Lists only: tree positions also have a depth.
See Accessibility for a row with move buttons.
startDrag: a scripted drag
const { startDrag } = useDnDActions();
const drag = startDrag('card-1'); // null when it can't start now
if (drag) {
await drag.moveTo(document.querySelector('#trash')!); // onto an element
await drag.moveTo({ x: 400, y: 120 }); // to a point, in viewport coordinates
await drag.moveTo({ list: 'done', index: 2 }); // to a list position
await drag.drop(); // true when the drop went through; or drag.cancel()
}
moveTo resolves once the move has rendered, drop once the drop has landed. The demo's
"Play a scripted drag" glides the last track up in small moveTo steps, then drops it at the top.
startDrag(id, { from: { x, y } }) picks the item up at a point instead of its center.
Hand a drag to the keyboard
startDrag(id, { type: 'keyboard' }) lifts the item as a keyboard drag. Your code can move it with
await drag.step('down'), or leave it to the user: the keyboard sensor drives every keyboard drag,
whoever started it. That makes a "Move" menu item:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Planner from './Planner.vue';
</script>
<template>
<!-- The ⋯ menus move rows with @vue-dnd-kit/actions. -->
<DnDProvider>
<Planner />
</DnDProvider>
</template>const { moveItem, startDrag } = useDnDActions();
// One move in one call: picked up, put there and dropped, announced and animated.
const toOther = () => moveItem(props.task.id, { list: props.other.id, index: 0 });
// A keyboard drag, handed to the arrow keys: Space or Enter drops, Escape cancels.
const withKeys = () => {
el.value?.focus();
startDrag(props.task.id, { type: 'keyboard' });
};
The arrows move the row by slots, ArrowLeft and ArrowRight to the
other week (the lists share a group). When the drag ends, the focus goes back to the row. The
key hints under the board light up while useDnD().active.value?.sensor is 'keyboard'.
Outside components
useDnDActions() runs one sensor, named actions, in the provider above. To drive a provider from
a store or a test harness, or under a name of your own, create the actions in a sensor you run
yourself:
import { defineSensor } from '@vue-dnd-kit/core';
import { createDnDActions, type DnDActions } from '@vue-dnd-kit/actions';
export let tour: DnDActions | undefined;
export const tourSensor = defineSensor({
name: 'tour',
setup: (context) => void (tour = createDnDActions(context)),
});
<DnDProvider :sensors="[pointerSensor, keyboardSensor, tourSensor]">
Then await tour?.moveItem('card-1', { list: 'done' }) from anywhere. During server rendering
nothing drags: moveItem resolves false and startDrag returns null.