Draggable List

Reorder virtualized items using native drag and drop
Reorder items using native drag and drop. Virtualization maintains performance even during complex list mutations.

How to build a feature like this

Reordering a list whose rows are virtualized means only a handful of rows are ever in the DOM, so a drag can neither start from nor drop onto an element that is not currently mounted. The reliable approach is to keep the whole operation in data, not in the DOM: each mounted row reports its own index from the slot, that index is the drop target whenever the pointer is over the row, auto-scrolling mounts the rows in between, and the reorder itself is a single array splice performed on drop. Because rows recycle as you scroll, the two pieces of state that matter - which row is being dragged and which row is the current target - are plain refs that survive every unmount, and the virtualization engine re-ranges around the mutated array for you.

1. Make a row (or its handle) a drag source and record the origin

Native HTML5 drag and drop needs an element with the draggable attribute. Put it on the whole row, or - to avoid hijacking text selection and inner images - on a dedicated handle inside the row. Either way, attach the drag handlers to the mounted row: dragstart/dragover/drop/dragend bubble from the handle to the row's listeners. On dragstart, capture the slot index into a draggedIndex ref, set effectAllowed = 'move', store the index on the dataTransfer, and optionally anchor the drag image at the cursor so the row does not visually jump.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { ref } from 'vue';
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

const virtualScrollRef = ref<InstanceType<typeof VirtualScroll> | null>(null);
const list = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Item ${i}` })));

// The drag is tracked in data (indices), never in the DOM: rows mount and
// unmount as you scroll, but these two refs survive every recycle.
const draggedIndex = ref<number | null>(null);
const dropTargetIndex = ref<number | null>(null);

function onDragStart(index: number, e: DragEvent) {
  draggedIndex.value = index;
  e.dataTransfer!.effectAllowed = 'move';
  e.dataTransfer!.setData('text/plain', String(index));
  const el = e.currentTarget as HTMLElement;
  if (e.dataTransfer!.setDragImage) {
    e.dataTransfer!.setDragImage(el, e.offsetX, e.offsetY); // cursor stays put
  }
}

function onDragOver(index: number, e: DragEvent) {
  e.preventDefault(); // required or the drop is rejected
  dropTargetIndex.value = index; // the row under the cursor becomes the target
  edgeAutoScroll(e);
}

function onDrop() {
  stopAutoScroll();
  if (draggedIndex.value !== null && dropTargetIndex.value !== null) {
    const next = [ ...list.value ];
    const [ moved ] = next.splice(draggedIndex.value, 1);
    next.splice(dropTargetIndex.value, 0, moved);
    list.value = next; // fresh array identity -> reactive re-range by engine
  }
  draggedIndex.value = dropTargetIndex.value = null;
}

function onDragEnd() {
  stopAutoScroll();
  draggedIndex.value = dropTargetIndex.value = null;
}
</script>

2. Make the drop target an index, not a DOM element

An unmounted row can never receive dragover, so the only rows you can drop onto are the ones currently in the window - and their identity can change under your cursor as the list scrolls. Because every mounted row already knows its position from the slot, the simplest correct target is that index: on dragover.prevent (the .prevent is required or the browser rejects the drop) set dropTargetIndex to the row's index. There is no offset arithmetic because the library hands each row its own index. An alternative when you want a whole-list drop zone is to compute the target from the pointer instead: the instance exposes getRowIndexAt(offset)/scrollToOffset helpers - useful with pointer events, but unnecessary when each row can report its index directly.

<template>
  <VirtualScroll
    virtual-scrollbar
    ref="virtualScrollRef"
    class="vs"
    :items="list"
    aria-label="Reorderable list"
  >
    <template #item="{ item, index }">
      <div
        class="row"
        :class="{
          'is-dragging': draggedIndex === index,
          'is-target': dropTargetIndex === index && draggedIndex !== index,
        }"
        @dragstart="onDragStart(index, $event)"
        @dragover.prevent="onDragOver(index, $event)"
        @drop="onDrop"
        @dragend="onDragEnd"
      >
        <!-- Only the handle is draggable; the row's handlers fire via bubbling.
             Keep selectable text/images out of the drag surface. -->
        <span class="handle" draggable="true" aria-hidden="true">⠿</span>
        <strong>{{ item.label }}</strong>
      </div>
    </template>
  </VirtualScroll>
</template>

3. Auto-scroll to reach targets outside the window

Dragging to a row far below (or above) the viewport cannot work by waiting for the pointer to cross it - the target is not mounted. Drive the scroll yourself while the pointer sits in an edge zone of the container: repeatedly call the instance's programmatic scroll (reading the current offset from scrollDetails.scrollOffset and nudging it with scrollToOffset), which mounts the intermediate rows under the cursor until the desired index appears. Virtualization means you never manipulate a wrapper's scrollTop - always go through the exposed scroll methods so the engine keeps its internal state consistent.

// Auto-scroll while the pointer rests in the top/bottom edge zone. Unmounted
// rows can't be drop targets, so we scroll (which mounts more rows) until the
// wanted index comes under the cursor. `virtualScrollRef` is the component ref.
let raf = 0;

function edgeAutoScroll(e: DragEvent) {
  const host = (e.currentTarget as HTMLElement).closest('.virtual-scroll-container');
  const rect = host?.getBoundingClientRect();
  if (!rect) return;
  const zone = 60;
  const delta = e.clientY < rect.top + zone ? -12 : e.clientY > rect.bottom - zone ? 12 : 0;
  cancelAnimationFrame(raf);
  if (delta === 0) return;
  raf = requestAnimationFrame(() => {
    const vs = virtualScrollRef.value;
    if (!vs) return;
    const y = vs.scrollDetails.scrollOffset.y; // current virtual offset
    vs.scrollToOffset(null, y + delta, { behavior: 'auto' }); // nudge the axis
  });
}

function stopAutoScroll() {
  cancelAnimationFrame(raf);
  raf = 0;
}

4. Commit one splice on drop, then clean up

Reorder only on drop, never live while hovering: if you mutated the array on every dragover, the indices you are comparing would drift mid-drag. On drop, remove the item at draggedIndex and insert it at dropTargetIndex, assigning a fresh array so the change is reactive; the engine re-ranges around the current scroll and re-measures, so the visual position is preserved. Because a drop can be cancelled (Esc, leaving the window), also reset both refs and stop any auto-scroll in dragend. Add lightweight feedback from the same state: dim the carried row (draggedIndex) and show an insertion marker on the target row (dropTargetIndex). If your rows are all the same height, sizing them arithmetically with a numeric item-size makes offsets deterministic and the offset-based drop-zone alternative exact - but drag reorder itself is agnostic to measured vs. fixed rows.

.row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 1rem;
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
}
.handle {
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
}
.row.is-dragging {
  opacity: 0.3;
} /* the row being carried */
.row.is-target {
  border-top: 3px solid oklch(55% 0.2 260);
} /* drop marker */
A
A Item 0
ID: 0
B
B Item 1
ID: 1
C
C Item 2
ID: 2
D
D Item 3
ID: 3
E
E Item 4
ID: 4
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y