Drag and Drop in Vue Without UI Chaos
AI generated
<v/>
{ }
Vue 3 · Drag and Drop · HTML5 API · vue-draggable · UX
Drag and Drop in Vue Without UI Chaos
from the HTML5 API to a Kanban board with touch support

Drag and drop looks simple, until you are debugging ghost images, touch events refuse to work on mobile devices and the state after the drop is inconsistent. Vue 3 provides all the building blocks for clean drag and drop, free of visual or logical chaos, through the Composition API and specialized libraries.

15 min read HTML5 Drag API · vue-draggable-plus · Touch Events · Kanban · Accessibility Vue 3 · TypeScript · SortableJS

1. Why drag and drop in Vue is complex

Drag and drop in Vue starts with a question: which layer takes on which responsibility? The HTML5 Drag API supplies the browser events, but state management, which element is being dragged, which drop target the cursor is pointing at, and at which position it should land, is entirely the responsibility of the JavaScript code. Vue's reactivity system is ideally suited for this, because state changes appear in the UI instantly, without manual DOM manipulation. The challenge lies in defining the drag-and-drop state model cleanly before starting the implementation.

The most common problems with drag and drop in Vue do not arise from technical difficulties but from conceptual ambiguity: ghost images that reflect the wrong state because they are created too early or too late. State inconsistencies after a drop, because the data structure was not updated atomically. Missing touch event support, because the HTML5 Drag API works only partially, or not at all, on mobile devices. And missing keyboard navigation, which makes drag and drop inaccessible to keyboard and screen reader users. This article addresses all four problem categories.

2. Using the HTML5 Drag and Drop API directly in Vue

The HTML5 Drag API works through a system of events: dragstart (start of the drag), drag (during the drag), dragend (end of the drag), and on the drop target: dragenter, dragover, dragleave and drop. For an element to be draggable, it needs the HTML attribute draggable="true". In Vue, you set this as a bound attribute: :draggable="true", or on all list elements through a component. The dataTransfer object in the dragstart event is the communication channel between the drag source and the drop target: you write data with setData() and read it in the drop event with getData().

An important aspect of the HTML5 Drag API in drag and drop in Vue: the dragover event must be handled with event.preventDefault() so the browser allows the drop. Without this line, the browser refuses the drop operation. dataTransfer.dropEffect and dataTransfer.effectAllowed control which cursor icon the browser shows during the drag: copy, move, link or none. For most drag and drop in Vue scenarios, move is the right choice, since elements are being moved from one position to another.


<!-- SortableList.vue - basic drag-and-drop reordering with HTML5 API -->
<template>
  <ul class="space-y-2">
    <li
      v-for="(item, index) in items"
      :key="item.id"
      draggable="true"
      class="p-4 bg-white border border-slate-200 rounded-xl cursor-grab active:cursor-grabbing"
      :class="{ 'opacity-40 scale-95': dragState.draggingId === item.id }"
      @dragstart="onDragStart($event, item.id, index)"
      @dragend="onDragEnd"
      @dragover.prevent="onDragOver($event, index)"
      @drop.prevent="onDrop($event, index)"
      @dragleave="onDragLeave"
    >
      <slot :item="item" :isDragging="dragState.draggingId === item.id" />
    </li>
  </ul>
</template>

<script setup lang="ts">
import { reactive } from 'vue'

interface Item { id: string | number }

const props = defineProps<{ items: Item[] }>()
const emit = defineEmits<{ reorder: [from: number, to: number] }>()

const dragState = reactive({
  draggingId: null as string | number | null,
  fromIndex: -1,
  overIndex: -1,
})

function onDragStart(event: DragEvent, id: string | number, index: number) {
  dragState.draggingId = id
  dragState.fromIndex = index
  // Set drag data for cross-component communication
  event.dataTransfer!.setData('text/plain', String(id))
  event.dataTransfer!.effectAllowed = 'move'
}

function onDragOver(event: DragEvent, index: number) {
  event.dataTransfer!.dropEffect = 'move'
  dragState.overIndex = index
}

function onDrop(event: DragEvent, toIndex: number) {
  if (dragState.fromIndex !== -1 && dragState.fromIndex !== toIndex) {
    emit('reorder', dragState.fromIndex, toIndex)
  }
  resetDragState()
}

function onDragEnd() { resetDragState() }
function onDragLeave() { dragState.overIndex = -1 }

function resetDragState() {
  dragState.draggingId = null
  dragState.fromIndex = -1
  dragState.overIndex = -1
}
</script>

3. A useDragAndDrop composable for simple scenarios

For simple drag-and-drop scenarios in Vue, a single sortable list, a handful of drop targets, a dedicated useDragAndDrop composable is the most maintainable solution. The composable manages the entire drag state as a reactive object and exposes event handlers as functions. The component imports the composable, binds the handlers to the DOM elements, and no longer contains any drag-and-drop logic. The result is a component that contains only rendering logic, and a testable composable for the drag-and-drop behavior.

The reorder function is the core of the drag and drop in Vue composable: it takes the source and target position and updates the items array atomically. The classic pattern: remove the element at the source index and insert it at the target index. Vue's reactivity system ensures the template re-renders immediately and shows the new order. A common source of bugs: the indexes must be adjusted after removing the element, because removing an element before the target index shifts the target index by one. The composable encapsulates this calculation and prevents off-by-one bugs in the component.

4. vue-draggable-plus for sortable lists

vue-draggable-plus is the recommended library for drag and drop in Vue 3. It is built on SortableJS, the same library also used in the widely known vue-draggable for Vue 2, and is written entirely for Vue 3 with TypeScript. The central pattern is refreshingly simple: replace the wrapping list element with a <VueDraggable> component and pass the reactive array via v-model. The library takes care of all the drag events, ghost image creation and the state change in the array, without you having to write a single drag event handler.

The v-model binding is the key to clean state management: the library mutates the array directly after a successful drop. Because it is a reactive ref array, Vue updates the UI automatically. For scenarios with server-side persistence, you register on the @update event of the <VueDraggable> component and send the new order to the API. That cleanly separates the visual reordering (instant, optimistic) from persistence (asynchronous, with error handling). If the API request fails, the array can be reset to its previous state.

5. Kanban board: drag and drop across lists

A Kanban board is the most demanding standard use case for drag and drop in Vue: cards are not only reordered within a column but also moved between columns. That requires a state model that manages multiple lists and represents cross-list moves atomically. With vue-draggable-plus this is solved via the group option: all lists that belong to the same drag-and-drop context receive the same group name. The library then automatically allows dragging elements between these lists.

The state model of a Kanban board in Vue typically follows this pattern: a reactive object or a Pinia store holds a dictionary from column ID to card array. When a card is dragged from column A to column B, the card object is removed from column A's array and inserted into column B's array. With vue-draggable-plus, this happens automatically through the v-model binding, but the code must implement @add and @remove event handling if persistence stores the column ID as part of the card. Drag and drop in Vue for Kanban boards pays off considerably in maintainability by decoupling the visual interaction from the state mutation.


<!-- KanbanBoard.vue - cross-list drag and drop with vue-draggable-plus -->
<template>
  <div class="flex gap-4 overflow-x-auto pb-4">
    <div
      v-for="column in columns"
      :key="column.id"
      class="flex-shrink-0 w-72 bg-slate-100 rounded-2xl p-4"
    >
      <h3 class="font-bold text-slate-800 mb-3">{{ column.title }}</h3>

      <VueDraggable
        v-model="column.cards"
        :group="{ name: 'kanban', pull: true, put: true }"
        item-key="id"
        class="min-h-16 space-y-2"
        ghost-class="opacity-30"
        chosen-class="shadow-xl scale-105"
        drag-class="rotate-2"
        animation="200"
        @add="(event) => onCardMoved(event, column.id)"
      >
        <template #item="{ element: card }">
          <div
            class="bg-white rounded-xl p-3 shadow-sm cursor-grab active:cursor-grabbing border border-slate-200"
          >
            <p class="font-semibold text-sm text-slate-800">{{ card.title }}</p>
            <p class="text-xs text-slate-500 mt-1">{{ card.assignee }}</p>
          </div>
        </template>
      </VueDraggable>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import type { SortableEvent } from 'sortablejs'

interface Card { id: string; title: string; assignee: string; columnId: string }
interface Column { id: string; title: string; cards: Card[] }

const columns = ref<Column[]>([
  { id: 'todo', title: 'To Do', cards: [
    { id: '1', title: 'Login page', assignee: 'Max', columnId: 'todo' },
    { id: '2', title: 'API integration', assignee: 'Lisa', columnId: 'todo' },
  ]},
  { id: 'in-progress', title: 'In Progress', cards: [] },
  { id: 'done', title: 'Done', cards: [] },
])

async function onCardMoved(event: SortableEvent, targetColumnId: string) {
  const cardId = event.item.dataset.id
  // Persist column change to API - optimistic update already applied by vue-draggable
  try {
    await updateCardColumn(cardId!, targetColumnId)
  } catch {
    // On API failure: revert the optimistic update
    // (re-fetch or restore previous state from snapshot)
  }
}

async function updateCardColumn(cardId: string, columnId: string) {
  await fetch(`/api/cards/${cardId}`, {
    method: 'PATCH',
    body: JSON.stringify({ columnId }),
    headers: { 'Content-Type': 'application/json' },
  })
}
</script>

6. Custom ghost image and drag overlay

The default ghost the browser shows during drag and drop in Vue is a pixelated snapshot of the dragged element, often blurry and visually unappealing. With dataTransfer.setDragImage(), this ghost image can be replaced with any DOM element. The pattern: a custom element is briefly added to the DOM right before the drag starts, registered as the ghost image, and removed again immediately afterward. The Drag API then works with the registered reference, even though the element has been removed from the DOM.

A more elegant alternative for drag and drop in Vue is a fully separate overlay element that tracks the mouse position. This overlay is not a browser ghost but a regular Vue component that sticks to the pointer using absolute positioning. Mouse position is tracked via mousemove (or pointermove) and the overlay is moved with a CSS transform. This approach gives you complete control over the visual appearance of the drag element, but requires its own DnD state manager, since you are no longer using the HTML5 Drag API for the visual feedback.

7. Touch support and mobile UX

The HTML5 Drag API works only partially, or not at all, on mobile devices with iOS and Android. Touch devices do not know drag events, only touch events: touchstart, touchmove and touchend. Anyone who wants to offer drag and drop in Vue on mobile devices as well must translate touch events into drag behavior manually, or use a library that already does so. SortableJS, and with it vue-draggable-plus, has built-in touch support: it detects touch events and emulates drag-and-drop behavior without additional configuration.

An important UX aspect of drag and drop in Vue on mobile devices is distinguishing between a scroll swipe and the start of a drag. SortableJS solves this with a configurable delay option: the drag only begins after a brief hold time of, say, 150 to 200 milliseconds. Below that time threshold, the system interprets the touch gesture as a scroll. This should be communicated in the UI: visual feedback (a light vibration through the Vibration API, a color or size change of the element) signals to the user that the drag has begun. Without this feedback, mobile drag and drop feels uncontrolled and unreliable.

8. Accessibility: keyboard alternatives to drag and drop

Drag and drop in Vue is inaccessible without an alternative for users with motor impairments, keyboard users and screen reader users. The WCAG guidelines (Success Criterion 2.1.1) require that all functionality reachable by mouse must also be operable by keyboard. For sorting tasks, that means concretely: each sortable element gets a key or key combination to switch into a "selected for moving" mode, and arrow keys move the element within the list. An aria-grabbed attribute (deprecated in ARIA 1.1, but still useful) and corresponding aria-live regions give screen reader users feedback about the current state.

The practical implementation of the keyboard alternative for drag and drop in Vue is a separate system alongside mouse dragging: pressing space or enter on a sortable element marks it as "picked up". Arrow keys move it within the list. Pressing space or enter again "drops it". This interaction is entirely representable through reactive state, no DOM drag event, only keyboard events and array mutation. vue-draggable-plus does not yet have native keyboard support, which is why it should be implemented as a separate useKeyboardSort composable that works alongside the drag and drop.

9. Drag-and-drop libraries compared

Choosing the library for drag and drop in Vue 3 depends on the use case, the desired browser compatibility and the required touch support.

Library Vue 3 Touch support Strengths
HTML5 Drag API Native Limited No dependency, full control
vue-draggable-plus Yes (Vue 3) Yes Simple, groups, animations
dnd-kit (React) No Yes Relevant only for React
Draggable (Shopify) Manually integrable Yes Built-in accessibility features
VueDraggable (v2) Vue 2 only Yes Not for new Vue 3 projects

For new Vue 3 projects, vue-draggable-plus is the clear recommendation: native Vue 3 compatibility with TypeScript, touch support via SortableJS, cross-list drag via groups and a simple v-model binding. For projects with high accessibility requirements, a combination of vue-draggable-plus for mouse and touch, plus a custom useKeyboardSort composable for keyboard users, is recommended.

Mironsoft

Vue 3 frontend development, interaction design and accessibility engineering

Drag-and-drop feature with state problems or missing touch support?

We implement complete drag-and-drop solutions in Vue 3, with touch support, custom ghost images, cross-list drag, keyboard alternatives and persistent state synchronization.

Kanban board

Full Kanban with cross-list drag, persistence and optimistic updates

Touch & mobile

Drag and drop on iOS and Android too, with a visually polished UX

Accessibility

Keyboard alternatives and ARIA attributes for full WCAG compliance

10. Summary

Professional drag and drop in Vue 3 starts with the right state model: which element is being dragged, where can it fall, and how is the reorder operation performed atomically? The HTML5 Drag API supplies the browser events, Vue's reactivity system handles the state management. For simple lists, a custom composable is enough; for sortable lists and cross-list drag, vue-draggable-plus is the recommended library, with a simple v-model binding and built-in touch support via SortableJS.

The three commonly overlooked aspects of drag and drop in Vue: ghost images should be customized rather than relying on the browser default. Touch support must be tested explicitly, iOS and Android behave differently with the HTML5 Drag API. And keyboard alternatives are not an optional extra but an accessibility requirement. A useKeyboardSort composable running alongside the drag and drop makes the feature accessible to all users, without compromising the mouse UX.

Drag and Drop in Vue, the essentials at a glance

State model

draggingId, fromIndex, overIndex as a reactive object. Run the reorder function atomically. Never manipulate the DOM directly, let Vue handle the rendering.

vue-draggable-plus

v-model for automatic array mutation. group option for cross-list drag. animation option for sort animations. Touch support included.

Touch & mobile

HTML5 Drag API is not reliable on mobile. SortableJS/vue-draggable-plus have built-in touch support. delay option prevents conflict with scroll gestures.

Accessibility

WCAG 2.1.1: keyboard alternative required. Space/enter to pick up, arrow keys to move. aria-live region for screen reader feedback.

11. FAQ: Drag and Drop in Vue Without UI Chaos

1HTML5 Drag API not working on mobile?
Mobile only knows touch events. Use vue-draggable-plus or SortableJS directly, both reliably emulate drag through touch events.
2vue-draggable vs. vue-draggable-plus?
vue-draggable = Vue 2, deprecated. vue-draggable-plus = Vue 3, TypeScript, v-model, actively developed. Always use vue-draggable-plus for new projects.
3Passing data between drag and drop?
dataTransfer.setData() / getData() for browser communication. For Vue-internal communication: a reactive dragState object, cleaner and type-safe.
4Flickering drag overlays?
Counter approach: dragenter ++, dragleave --. isDragging = counter > 0. No flicker with child elements.
5Persisting the order?
Update optimistically via v-model, send to the API asynchronously. On failure: reset to the snapshot and show an error message.
6Cross-list drag with vue-draggable-plus?
group option: give all participating Draggables the same group name. pull: true = removal allowed, put: true = dropping allowed.
7WCAG compliance for drag and drop?
Keyboard alternative required (WCAG 2.1.1). Space to pick up, arrow keys to move, aria-live for screen readers.
8Creating a custom ghost image?
Create the element in dragstart, add it to the DOM, call setDragImage(), remove it immediately with setTimeout. The browser retains the reference.
9Why is :key critical for sortable lists?
Without :key, Vue misassigns components. For drag and drop, always use the element's stable ID as :key, never the array index.
10Showing a drop zone only during drag?
v-show='isDragging' on the drop zone. isDragging in the global drag state (reactive or Pinia): dragstart -> true, dragend -> false.