Draggable List

Reorder virtualized items using native drag and drop
Reorder rows with native drag and drop. The list stays virtualized while the order changes underneath the drag.

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. Keep the operation in data: the drag carries an index, the pointer decides which row it is over, auto-scrolling mounts the rows in between, and the reorder is a single array splice on drop. Rows recycle and the content shifts under a stationary pointer while auto-scrolling, so the target is resolved from the pointer position instead of being remembered from the last event - and the two refs that hold the drag (which row is carried, which row is marked) survive every unmount while the engine re-ranges around the mutated array.

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. 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
  }
}

// The pointer decides the target, not the event target: rows unmount, and the
// content moves under a still pointer while auto-scrolling.
function rowIndexAt(x: number, y: number) {
  const row = document.elementFromPoint(x, y)?.closest<HTMLElement>('[data-row-index]');
  return row ? Number((row as HTMLElement).dataset.rowIndex) : null;
}

function onDragOver(e: DragEvent) {
  e.preventDefault(); // required or the drop is rejected
  dropTargetIndex.value = rowIndexAt(e.clientX, e.clientY); // the row under the cursor
  edgeAutoScroll(e);
}

function onDrop(e: DragEvent) {
  stopAutoScroll();
  const from = draggedIndex.value;
  const to = rowIndexAt(e.clientX, e.clientY) ?? dropTargetIndex.value;
  if (from !== null && to !== null && from !== to) {
    const next = [ ...list.value ];
    const [ moved ] = next.splice(from, 1);
    // The marker sits above its row, and removing the carried row shifts every
    // row below it up by one, so a downward move inserts one index earlier.
    next.splice(to > from ? to - 1 : to, 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 which row sits under the pointer changes as the list scrolls, even while the pointer is still. Have every mounted row publish its index (data-row-index="index"), then resolve the target from the pointer with document.elementFromPoint: the row under the cursor is the one the user means, whether or not it was mounted when the drag started. On dragover.prevent (the .prevent is required or the browser rejects the drop) refresh that resolution - and re-run it from the auto-scroll tick too, otherwise the marker, and the drop that follows it, lag behind the rows moving underneath. The instance's getRowIndexAt(offset) is the arithmetic equivalent when every row is the same height; the DOM lookup needs no size assumptions at all.

Cancel the drop event the same way (@drop.prevent). A drop that no handler cancels is left to the browser, and the payload carried here is text: Safari - on iOS above all - searches the web for the dropped text and loads the results, so a release that misses the rows navigates away. Rows only cover the pixels they occupy, so while a row is carried, cancel dragover and drop at the window level as well. The rows still resolve the target from the pointer, and a release over the page around the list then does nothing.

<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 }"
        :data-row-index="index"
        @dragstart="onDragStart(index, $event)"
        @dragover.prevent="onDragOver($event)"
        @drop.prevent="onDrop"
        @dragend="onDragEnd"
      >
        <!-- Overlay marker: a border would change the row's measured height mid-drag. -->
        <span v-if="dropTargetIndex === index && draggedIndex !== index" class="row-marker" aria-hidden="true" />
        &lt;!-- 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 timer: ReturnType<typeof setInterval> | null = null;
let pointer = { x: 0, y: 0 };

function edgeAutoScroll(e: DragEvent) {
  pointer = { x: e.clientX, y: e.clientY };
  const host = (e.currentTarget as HTMLElement).closest('.virtual-scroll-container');
  const rect = host?.getBoundingClientRect();
  if (!rect || timer) return;
  const zone = 60;
  const delta = pointer.y < rect.top + zone ? -10 : pointer.y > rect.bottom - zone ? 10 : 0;
  if (delta === 0) return;
  timer = setInterval(() => {
    // Rows move under a still pointer, so the target is re-resolved on every step -
    // requestAnimationFrame is throttled in background tabs, an interval is not.
    dropTargetIndex.value = rowIndexAt(pointer.x, pointer.y);
    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
  }, 16);
}

function stopAutoScroll() {
  if (timer) clearInterval(timer);
  timer = null;
}

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 carried item and insert it at the marked row - the removal shifts every later row up by one, so a downward move inserts at target - 1 and an upward move at target. Assign 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 mark the target row (dropTargetIndex) with an absolutely positioned overlay - a border would change a measured row's height and re-run the layout you are dragging inside. If your rows are all the same height, sizing them arithmetically with a numeric item-size makes offsets deterministic and getRowIndexAt 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-marker {
  position: absolute;
  inset-inline: 0;
  top: -1px;
  height: 3px;
  background: oklch(55% 0.2 260);
} /* drop marker: an overlay, never a border */
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