Building Drag-and-Drop Interfaces in Vue
Implement drag-and-drop in Vue applications — sortable lists, kanban boards, file uploads, and the accessibility considerations most tutorials skip.
Strategic Systems Architect & Enterprise Software Developer
Drag-and-drop is one of the most satisfying interactions to use and one of the most frustrating to implement well. The browser's native drag-and-drop API is powerful but inconsistent across browsers and devices. Touch support requires additional handling. Accessibility requires a completely parallel interaction model. And the visual feedback during a drag operation — drop indicators, placeholder positioning, scroll behavior — determines whether the interaction feels polished or broken.
I have built drag-and-drop interfaces for project management boards, content editors, and file upload systems. Here is what actually works.
Choosing Your Approach
You have three options for drag-and-drop in Vue, and the choice matters.
Native HTML Drag and Drop API — works for simple cases like file drops onto a target zone. The API uses dragstart, dragover, dragenter, dragleave, and drop events. It is adequate for drag-from-desktop-to-browser scenarios but awkward for in-page reordering because it provides minimal visual feedback and no touch support.
Pointer events with manual positioning — you handle pointerdown, pointermove, and pointerup yourself, calculating positions and moving elements via CSS transforms. This gives you complete control over the visual experience but requires implementing hit detection, scroll behavior, and list reordering from scratch.
Libraries — VueDraggable (wrapping SortableJS), dnd-kit, or pragmatic-drag-and-drop. These handle the interaction mechanics and provide Vue-friendly APIs. For most applications, this is the right choice.
VueDraggable is the most established option in the Vue ecosystem. It wraps SortableJS with a Vue component interface:
<script setup lang="ts">
import draggable from 'vuedraggable'
Interface Task {
id: string
title: string
status: string
}
Const tasks = ref<Task[]>([
{ id: '1', title: 'Design mockups', status: 'todo' },
{ id: '2', title: 'API endpoints', status: 'todo' },
{ id: '3', title: 'Database schema', status: 'in-progress' },
])
</script>
<template>
<draggable
v-model="tasks"
item-key="id"
ghost-class="opacity-50"
animation="200"
@end="onDragEnd"
>
<template #item="{ element }">
<div class="rounded border bg-white p-4 shadow-sm cursor-grab active:cursor-grabbing">
{{ element.title }}
</div>
</template>
</draggable>
</template>
The ghost-class applies styles to the element being dragged. The animation prop adds smooth transitions when items reorder. These details make the difference between "it works" and "it feels right."
Kanban Board Implementation
Multi-column drag-and-drop — moving items between lists — is the most common complex case. Each column is its own draggable container, and items can move between them. The key is sharing a group name across columns:
<script setup lang="ts">
const columns = ref([
{ id: 'todo', title: 'To Do', tasks: [...] },
{ id: 'in-progress', title: 'In Progress', tasks: [...] },
{ id: 'done', title: 'Done', tasks: [...] },
])
Function onMoveTask(event: { added?: unknown; removed?: unknown }) {
// Persist the new order to the backend
syncTaskOrder()
}
</script>
<template>
<div class="grid grid-cols-3 gap-6">
<div v-for="column in columns" :key="column.id" class="rounded-lg bg-neutral-50 p-4">
<h2 class="mb-4 text-lg font-semibold">{{ column.title }}</h2>
<draggable
v-model="column.tasks"
group="kanban"
item-key="id"
class="min-h-[100px] space-y-2"
@change="onMoveTask"
>
<template #item="{ element }">
<TaskCard :task="element" />
</template>
</draggable>
</div>
</div>
</template>
The group="kanban" setting allows items to be dragged between any column that shares the group name. The min-h-[100px] on each column ensures there is a visible drop target even when a column is empty — without it, users cannot drop items into an empty column because there is no area to drop onto.
Persistence is critical. When a user moves a task from "To Do" to "Done," that change must be saved immediately. Optimistic updates — updating the UI first, then sending the API request — provide the best user experience. If the API call fails, revert the UI and show an error notification. This pattern aligns with the state management approaches used for other optimistic UI updates.
Touch Support and Mobile Considerations
Touch devices add complexity because touch events conflict with scrolling. When a user puts their finger on a draggable item, the browser does not know whether they intend to drag the item or scroll the page. SortableJS handles this with a delay — the user must press and hold for a moment before the drag activates.
<draggable
v-model="items"
item-key="id"
:delay="150"
:delay-on-touch-only="true"
>
The delay-on-touch-only setting applies the delay exclusively on touch devices, keeping desktop drag instant. 150 milliseconds is enough to distinguish a drag intention from a scroll without feeling sluggish.
On narrow viewports, horizontal kanban boards need a different approach. A common pattern is converting the horizontal board to a vertical accordion where each column expands on tap. Dragging between columns still works, but the spatial arrangement changes to match the viewport. Do not force horizontal scrolling on a kanban board at mobile widths — the interaction is frustrating and the content is nearly unreadable.
Accessibility: The Keyboard Alternative
Drag-and-drop is inherently a pointer-based interaction. Keyboard users and screen reader users need a parallel mechanism that achieves the same result. This is not optional — it is a core accessibility requirement.
The most effective pattern is providing action buttons on each draggable item that appear on focus:
<template>
<div
role="listitem"
:aria-label="`${task.title}, position ${index + 1} of ${total}`"
class="group"
>
<span>{{ task.title }}</span>
<div class="opacity-0 group-focus-within:opacity-100">
<button
aria-label="Move up"
@click="moveItem(index, index - 1)"
:disabled="index === 0"
>
↑
</button>
<button
aria-label="Move down"
@click="moveItem(index, index + 1)"
:disabled="index === total - 1"
>
↓
</button>
</div>
</div>
</template>
For kanban boards, replace the up/down buttons with a dropdown that lets the user select the target column. Announce the result of the action using an ARIA live region so screen reader users receive confirmation.
The keyboard interface does not need to replicate the drag-and-drop animation. It needs to achieve the same outcome — reordering items or moving them between groups — through a different interaction model. Do not try to make keyboard users "drag" with arrow keys. Give them direct actions that are clearer than dragging anyway.