Drag-and-Drop Row Reordering Inside a Virtual-Scrolling Table
Universal Virtual Scrolling Table Row Drag Sorting: From Concept to Complete Implementation
Suppose you are developing a table with 100,000 rows of data. For performance, you use virtual scrolling, rendering only the dozen or so rows in the visible area on screen. Now the user wants to drag row 9527 to before row 3.
The problem: row 3 might not even be in the DOM, and there is no "target row" under the mouse to drop onto. Worse, if the table also has filtering enabled, the row numbers themselves are dynamically changing. How do we know where the user actually wants to insert?
This article first explains the core concept, then provides complete, usable code. We do not rely on global indices, only on each row's unique identifier Idd. Using a fixed row height and the current render window, we precisely calculate the insertion position during mouse movement.
1. Mental Model: Three Data Layers and Idd
In a virtual scrolling table, there are three data views:
Full data allData: [A, B, C, D, E, F, G, H, I, J] (possibly filtered)
↓ virtual scrolling takes a window
Window data showGridData: [C, D, E, F, G, H] (the DOM rows actually rendered)
↓
DOM rows: <tr C> <tr D> <tr E> <tr F> <tr G> <tr H>
- Full data is all the data held by the business side, possibly affected by filtering and sorting.
- Window data is the array of rows that the virtual scrolling component calculates as needing to be rendered based on the scroll position. It has a strict one-to-one correspondence and consistent order with the
<tr>elements in the DOM. - Idd is the unique identifier for each row of data, unchanging with position, filtering, or scrolling.
The entire drag process uses only Idd to represent "what is being dragged" and "where it is dragged to". Coordinate calculations are based on the showGridData window snapshot.
showGridDatausually includes not only the rows in the visible area but also a few rows above and below as a buffer (overscan). The buffer's role will be explained in the core concept.
2. The Complete Journey of a Drag
pointerdown (press handle)
↓
Record fromIdd and fromNextIdd
Create ghost row
↓
pointermove (move)
↓
Update ghost row position
Calculate target Idd
Update indicator line
Trigger auto-scroll if necessary
↓
pointerup (release)
↓
Determine if target differs from original position
Call onSort(fromIdd, toIdd)
Clean up temporary state
There are two core challenges:
- Identity stability: Use only Idd to represent the drag source and target, not relying on volatile indices.
- Mapping coordinates to Idd: How to convert the mouse's screen coordinates into the Idd of "which row to insert before".
Let's first explain the solution to the second challenge, which is the core of the entire scheme.
3. Core Concept: From Mouse Coordinates to Target Idd
3.1 Unified Coordinate System
The indicator line's top is an offset relative to the scroll container's content area and scrolls with the content. Therefore, we must also convert the mouse's screen coordinates into content coordinates so the two can be compared.
Conversion formula:
mouseTopInContent = (clientY - containerRect.top) + container.scrollTop
clientY - containerRect.top: the mouse's Y offset within the container's viewport.+ container.scrollTop: add the already scrolled distance to get the mouse's Y coordinate relative to the content top.
3.2 Anchor Selection
With the mouse's content coordinates, we also need to know each row's position within the content. We use the first rendered row's offsetTop:
firstRowTopInContent = firstRowEl.offsetTop
This value is the top offset of the first rendered row within the scrolling content. It does not change with scrolling and is naturally a stable anchor. The first rendered row might be a buffer row (invisible), but this doesn't matter; as long as it's in the DOM, it provides the correct reference.
Assumption: The scroll container has
position: relativeset and no significant borders or padding. Most virtual scrolling component containers are cleandivs that satisfy this condition.
3.3 Calculating the Offset Row Count
Both values are in the content coordinate system. Subtract them and divide by the row height to get how many rows the mouse is offset by:
exact = (mouseTopInContent - firstRowTopInContent) / rowHeight
Insertion semantics are unified as "insert before the target row":
| exact | round(exact) | Meaning |
|---|---|---|
| 0.2 | 0 | Insert before row 0 |
| 0.5 | 1 | Insert before row 1 |
| 0.7 | 1 | Insert before row 1 |
| 1.4 | 1 | Insert before row 1 |
| -0.3 | 0 | Insert before row 0 (after clamp) |
Math.round(exact) perfectly implements the rule of "upper half inserts before, lower half inserts after". Then constrain within the window index range:
insertIndex = clamp(Math.round(exact), 0, totalRows)
insertIndex = totalRows means insert after the end of all rendered rows.
3.4 Index to Idd
The window index is only temporary; it must be converted to a stable Idd:
targetIdd = insertIndex < totalRows
? showGridData[insertIndex].Idd
: null
null means "insert at the end of the full data".
3.5 The Value of the Buffer
totalRows is the length of showGridData, and showGridData includes the visible area plus upper and lower buffers. This buffer is crucial for drag sorting:
Suppose the last row in the visible area is E, but showGridData also contains F, G, H (buffer). When the user drags a row to the visible bottom, we can still get F's Idd and precisely tell the business side "insert before F".
Without a buffer, totalRows would equal the number of visible rows. When the mouse is dragged to the visible bottom, insertIndex would directly equal totalRows, the target would become null (end of data), and precision would be greatly reduced. So the buffer not only improves the scrolling experience but also makes drag sorting more precise at the edges.
4. Indicator Line Positioning: Reusing the Same Calculation Result
The insertion point's Y coordinate within the scrolling content is simply:
indicatorTopInContent = firstRowTopInContent + insertIndex * rowHeight
When insertIndex = totalRows, the indicator line lands exactly at the bottom of the window content.
The indicator line's top directly uses content coordinates, requiring no extra conversion. Like the row elements, it is inside the scroll container and scrolls with the content, perfectly matching the semantics of "insert at a certain position in the content".
For performance, if the indicator line falls outside the visible area (with a 2px tolerance above and below), simply hide it.
5. Press and Release: Identity Judgment
5.1 What to Record on Press
When pressing the handle, record:
fromIdd: the Idd of the row being dragged.fromNextIdd: the Idd of the next row after the original position, used to determine whether "it actually moved".toIdd: initialized tofromNextIdd, i.e., the original position.
Why record fromNextIdd? Because the definition of "original position" is "the dragged row is still before its original next row". If toIdd still equals fromNextIdd (or equals fromIdd itself) on release, no effective move occurred.
5.2 How to Judge on Release
Suppose window data is [A, B, C], dragging B:
- The original position is "B is before C", i.e.,
fromNextIdd = 'C'. - If the user drags to the upper half of B,
toIdd = 'B'. At this pointtoIdd !== fromNextIdd, but the position actually hasn't changed.
So we need to exclude both cases simultaneously:
toIdd !== fromIdd && toIdd !== fromNextIdd
Only when the target is neither itself nor its original next row is it considered a real move, at which point onSort(fromIdd, toIdd) is called.
6. Ghost Row and Auto-Scrolling
6.1 Ghost Row
The ghost row is a clone of the dragged row, position: fixed following the mouse. Key points:
pointer-events: none: prevents the ghost row from blocking mouse events.- Append to
document.body: avoids being clipped by the container'soverflow. - Move using
transform: translateY(deltaY): does not trigger layout reflow, better performance. - Initial position completely overlaps the source row, then only offset in the Y direction.
6.2 Auto-Scrolling
When the mouse approaches the top or bottom edge of the container, start auto-scrolling. Each frame does three things:
- Determine if the mouse is still in the edge zone; if not, stop.
- Determine if scrolling can still continue (
canScrollUp/canScrollDown); if not, stop. - Modify
scrollTop, then recalculate the target Idd.
The last step is critical: after scrolling, showGridData will update, and the first rendered row element may be replaced, so the anchor must be re-fetched every frame and cannot be cached.
7. Complete Code
import {onBeforeUnmount, ref, toValue} from 'vue'
import {clamp} from '@/tools/Tool.js'
/**
* Virtual scrolling table row drag sorting composable
*
* @param {Object} options
* @param {number} options.rowHeight Row height (px), must match the table's actual row height
* @param {import('vue').Ref<[]>} options.showGridData All data actually displayed by virtual scrolling
* @param {Function} options.onSort Drag end callback (fromGlobalIdx, toGlobalIdx)
* @param {import('vue').ComputedRef<HTMLElement | null>} options.scrollContainer
* Computed property for the scroll container
* @param {number} [options.edgeZone=20] Height of the edge zone that triggers auto-scroll (px)
* @param {number} [options.scrollSpeed=8] Auto-scroll speed (px/frame)
* @param {string} [options.rowSelector='tbody tr'] Row element selector
* @returns {Object} Contains onPointerDown, getDragRowClass
*/
export function useVirtualDragSort(options) {
const {
rowHeight,
showGridData,
onSort,
scrollContainer,
edgeZone = 20,
scrollSpeed = 8,
rowSelector = 'tbody tr'
} = options
// ==================== Internal State ====================
/** Drag state: plain object, not exposed to template, avoids reactive overhead */
let dragState = null
/** Insert indicator line DOM element (lazily created) */
let indicatorEl = null
/** Auto-scroll requestAnimationFrame handle */
let autoScrollFrame = null
/** Scroll container DOM reference (lazily fetched then cached) */
let containerEl = null
/** Scroll container height cache, updated by ResizeObserver */
let containerHeight = 0
/** ResizeObserver instance, monitors container height changes */
let resizeObserver = null
/**
* Idd of the row currently being dragged (reactive)
* Used to dynamically bind row class in the template, solving the problem of inline style loss after virtual scrolling DOM recycling
*/
const draggingIdd = ref(null)
// ==================== Container and Monitoring ====================
/**
* Get scroll container (lazy initialization: fetched and cached only on first drag)
* @returns {HTMLElement | null}
*/
function getContainer() {
if (!containerEl) {
const el = toValue(scrollContainer)
if (el) {
containerEl = el
containerHeight = el.clientHeight
setupResizeObserver(el)
}
}
return containerEl
}
/**
* Set up ResizeObserver to monitor container height changes
* @param {HTMLElement} el Scroll container
*/
function setupResizeObserver(el) {
cleanupResizeObserver()
resizeObserver = new ResizeObserver(() => {
// Only update cached value on height change, no repeated reads
if (containerEl) {
containerHeight = containerEl.clientHeight
// Container size change may affect scrollWidth, refresh indicator line width
updateIndicatorWidth()
}
})
resizeObserver.observe(el)
}
/**
* Clean up ResizeObserver instance
*/
function cleanupResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
}
// ==================== DOM Utilities ====================
/**
* Create ghost row (fixed positioning, follows mouse)
* @param {HTMLElement} sourceEl The original row element being dragged
* @returns {HTMLElement} Ghost row element
*/
function createGhost(sourceEl) {
const ghost = sourceEl.cloneNode(true)
const rect = sourceEl.getBoundingClientRect()
// Basic styles: fixed positioning, dimensions matching original row
ghost.style.position = 'fixed'
ghost.style.top = `${rect.top}px`
ghost.style.left = `${rect.left}px`
ghost.style.width = `${rect.width}px`
ghost.style.height = `${rect.height}px`
ghost.style.zIndex = '9999'
ghost.style.pointerEvents = 'none' // Let mouse events pass through
ghost.style.opacity = '0.85'
ghost.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
ghost.style.margin = '0'
ghost.style.border = '1px solid #409eff'
ghost.style.boxSizing = 'border-box'
// If source row has drag highlight class, clone will carry it; ghost row doesn't need this class
ghost.classList.remove('is-dragging-source')
document.body.appendChild(ghost)
return ghost
}
/** Update indicator line width to equal the scroll container's full content width (scrollWidth) */
function updateIndicatorWidth() {
if (!indicatorEl || !containerEl) return
indicatorEl.style.width = `${containerEl.scrollWidth}px`
}
/**
* Create insert indicator line (absolutely positioned inside scroll container)
* @param {HTMLElement} container Scroll container
* @returns {HTMLElement} Indicator line element
*/
function createIndicator(container) {
// Ensure container can serve as reference point for absolute positioning
if (!container.style.position || container.style.position === 'static') {
container.style.position = 'relative'
}
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.height = '2px'
el.style.backgroundColor = '#409eff'
el.style.zIndex = '1000'
el.style.pointerEvents = 'none'
el.style.display = 'none'
container.appendChild(el)
return el
}
/**
* Get the first rendered row element inside the scroll container (queried in real-time; DOM changes dynamically under virtual scrolling)
* @param {HTMLElement} container Scroll container
* @returns {Element | null}
*/
function getFirstRowEl(container) {
return container.querySelector(rowSelector)
}
// ==================== Indicator Line Positioning ====================
/**
* Update insert indicator line position
* Indicator line is always at the top of the target row (i.e., insert before that row)
* When targetIndex is null, it means insert at the end; indicator line is at the content bottom (total rows * row height)
* @param {number|null} targetIndex Insertion point index (based on current render window), null means end
* @param {number} firstRowTopInContent Y coordinate of the first rendered row's top within the scrolling content
* @param {HTMLElement} container Scroll container
*/
function updateIndicator(
targetIndex,
firstRowTopInContent,
container
) {
if (!container || !indicatorEl) return
// Calculate indicator line's Y coordinate within the scrolling content
// If targetIndex is null, use current window total row count as index (i.e., content bottom)
const totalRows = toValue(showGridData).length
const effectiveIndex = targetIndex === null ? totalRows : targetIndex
const indicatorTopInContent = firstRowTopInContent + effectiveIndex * rowHeight
// Viewport clipping to avoid flickering near scrollbar
const scrollTop = container.scrollTop
const viewportTop = scrollTop
const viewportBottom = scrollTop + containerHeight
if (indicatorTopInContent < viewportTop - 2 || indicatorTopInContent > viewportBottom + 2) {
indicatorEl.style.display = 'none'
return
}
indicatorEl.style.top = `${indicatorTopInContent}px`
indicatorEl.style.display = 'block'
}
// ==================== Core Calculation ====================
/**
* Update target insertion row and indicator line based on mouse Y coordinate
* Target row Idd means "insert before this row"; if null, means insert at the end
* @param {number} clientY Mouse's clientY coordinate
*/
function updateTargetFromPoint(clientY) {
if (!dragState) return
const container = getContainer()
if (!container) return
const firstRowEl = getFirstRowEl(container)
if (!firstRowEl) return
const containerRect = container.getBoundingClientRect()
const firstRowRect = firstRowEl.getBoundingClientRect()
// Y coordinate of the first rendered row's top within the scrolling content
const firstRowTopInContent =
firstRowRect.top - containerRect.top - container.clientTop + container.scrollTop
// Mouse's Y coordinate within the scrolling content
const mouseTopInContent =
clientY - containerRect.top - container.clientTop + container.scrollTop
// Mouse offset relative to the first rendered row (in row height units)
const exact = (mouseTopInContent - firstRowTopInContent) / rowHeight
// Current window total row count (length of showGridData)
const totalRows = toValue(showGridData).length
// Insertion point index: round to nearest integer row position, range [0, totalRows]
const insertIndex = clamp(Math.round(exact), 0, totalRows)
// Target row Idd: null when insertion point is at the end, otherwise take corresponding row data
dragState.toIdd = insertIndex < totalRows
? toValue(showGridData)[insertIndex].Idd
: null
// Pass targetIndex (null means end) to updateIndicator
const targetIndex = insertIndex < totalRows ? insertIndex : null
updateIndicator(targetIndex, firstRowTopInContent, container)
}
// ==================== Auto-Scrolling ====================
/**
* Get auto-scroll edge thresholds
* @param {HTMLElement} container Scroll container
* @returns {{top: number, bottom: number}} Upper and lower thresholds (clientY coordinates)
*/
function getScrollThresholds(container) {
const rect = container.getBoundingClientRect()
return {
top: rect.top + edgeZone,
bottom: rect.top + containerHeight - edgeZone,
}
}
/**
* Determine if mouse is in the auto-scroll edge zone
* Uses module-level dragState.mouseY as current mouse Y coordinate
* @returns {boolean}
*/
function isInAutoScrollZone() {
if (!dragState) return false
const container = getContainer()
if (!container) return false
const {top, bottom} = getScrollThresholds(container)
return dragState.mouseY < top || dragState.mouseY > bottom
}
/**
* Determine if container can still scroll up
* @param {HTMLElement} container Scroll container
* @returns {boolean} Returns true if can scroll up, otherwise false
*/
function canScrollUp(container) {
return container.scrollTop > 0
}
/**
* Determine if container can still scroll down
* @param {HTMLElement} container Scroll container
* @returns {boolean} Returns true if can scroll down, otherwise false
*/
function canScrollDown(container) {
const maxScrollTop = container.scrollHeight - container.clientHeight
return container.scrollTop < maxScrollTop
}
/**
* Start auto-scrolling (continuously scrolls while mouse is in container edge zone)
*/
function startAutoScroll() {
if (autoScrollFrame) return
const step = () => {
const container = getContainer()
if (!container) return stopAutoScroll()
// Mouse not in edge zone, stop auto-scrolling
if (!isInAutoScrollZone()) return stopAutoScroll()
// Decide scroll direction based on mouse position
const {top} = getScrollThresholds(container)
if (dragState.mouseY < top) {
// Scroll up
if (!canScrollUp(container)) return stopAutoScroll()
container.scrollTop -= scrollSpeed
} else {
// Scroll down
if (!canScrollDown(container)) return stopAutoScroll()
container.scrollTop += scrollSpeed
}
// After scrolling, mouse position unchanged but content position changed; must recalculate insertion position
updateTargetFromPoint(dragState.mouseY)
// Continue next frame
autoScrollFrame = requestAnimationFrame(step)
}
autoScrollFrame = requestAnimationFrame(step)
}
/**
* Stop auto-scrolling
*/
function stopAutoScroll() {
if (autoScrollFrame) {
cancelAnimationFrame(autoScrollFrame)
autoScrollFrame = null
}
}
// ==================== Event Handling ====================
/**
* Global pointermove event handler
* @param {PointerEvent} e
*/
function onPointerMove(e) {
if (!dragState) return
// Update mouse coordinates
dragState.mouseY = e.clientY
// Update ghost row position (offset relative to start position)
const deltaY = e.clientY - dragState.startY
dragState.ghostEl.style.transform = `translateY(${deltaY}px)`
// Update insertion position and indicator line
updateTargetFromPoint(e.clientY)
// Determine if auto-scroll is needed based on current mouse position
if (isInAutoScrollZone()) startAutoScroll()
else stopAutoScroll()
}
/**
* Global pointerup event handler: end drag and trigger sorting
*/
function onPointerUp() {
if (!dragState) return
// Remove global event listeners
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
// Stop auto-scrolling
stopAutoScroll()
// Remove ghost row
dragState.ghostEl?.remove()
// Hide indicator line
if (indicatorEl) indicatorEl.style.display = 'none'
// Clear dragging row ID; template will automatically remove source row highlight class
draggingIdd.value = null
// Trigger sorting (only when target position differs from start position)
if (
dragState.toIdd !== dragState.fromIdd &&
dragState.toIdd !== dragState.fromNextIdd
) onSort(dragState.fromIdd, dragState.toIdd)
// Reset drag state
dragState = null
}
/**
* Drag handle pointerdown event: start drag
* @param {PointerEvent} e
* @param {Object} row Current row data (must include primary key Idd)
*/
function onPointerDown(e, row) {
// Only respond to left mouse button
if (e.button !== undefined && e.button !== 0) return
e.preventDefault()
// Get the row element being dragged (compatible with el-table internal structure)
const rowEl = e.target.closest(rowSelector)
if (!rowEl) return
// Get scroll container (lazy initialization)
const container = getContainer()
if (!container) return
// Create ghost row
const ghostEl = createGhost(rowEl)
// Set dragging row ID for template dynamic highlight class binding
draggingIdd.value = row.Idd
// Create indicator line (if not already)
if (!indicatorEl) {
indicatorEl = createIndicator(container)
// Initial width setting
updateIndicatorWidth()
}
// Index of dragged element in showGridData
const totalRows = toValue(showGridData)
const fromIndex = totalRows?.findIndex(r => r?.Idd === row?.Idd)
// Get Idd of the next row after dragged row; if last row, null, meaning insert at end
const fromNextIdd = totalRows?.[fromIndex + 1]?.Idd ?? null
// Initialize drag state
dragState = {
fromIdd: row.Idd,
fromNextIdd,
toIdd: fromNextIdd,
startY: e.clientY,
mouseY: e.clientY,
ghostEl,
}
// Register global events
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
}
// ==================== Row Class Calculation ====================
/**
* Calculate row class based on current drag state (for merging with other row class logic)
* @param {Object} params
* @param {Object} params.row - Row object
* @returns {string} Returns class string, e.g., 'is-dragging-source' or ''
*/
function getDragRowClass({row}) {
return row.Idd === draggingIdd.value ? 'is-dragging-source' : ''
}
// ==================== Cleanup ====================
/**
* Clean up all resources (called on component unmount)
*/
function destroy() {
// Remove global event listeners
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
// Stop auto-scrolling
stopAutoScroll()
// Clean up drag state
if (dragState) {
dragState.ghostEl?.remove()
dragState = null
}
// Remove indicator line
if (indicatorEl) {
indicatorEl.remove()
indicatorEl = null
}
// Clear dragging row ID
draggingIdd.value = null
// Clean up ResizeObserver and container reference
cleanupResizeObserver()
containerEl = null
containerHeight = 0
}
// Auto-cleanup on component unmount
onBeforeUnmount(destroy)
return {
onPointerDown,
getDragRowClass,
}
}
export default useVirtualDragSort
8. Usage Example
import { useVirtualDragSort } from './useVirtualDragSort'
const { onPointerDown, getDragRowClass } = useVirtualDragSort({
rowHeight: 25,
showGridData, // Ref, virtual scrolling window data (includes buffer)
scrollContainer, // ComputedRef<HTMLElement | null>
onSort: (fromIdd, toIdd) => {
const data = allData.value
const fromIndex = data.findIndex(r => r.Idd === fromIdd)
if (fromIndex === -1) return
const [moved] = data.splice(fromIndex, 1)
if (toIdd === null) {
data.push(moved)
} else {
const toIndex = data.findIndex(r => r.Idd === toIdd)
if (toIndex === -1) {
data.splice(fromIndex, 0, moved) // fallback
return
}
data.splice(toIndex, 0, moved)
}
},
})
Template:
<tr
v-for="row in showGridData"
:key="row.Idd"
:class="getDragRowClass({ row })"
@pointerdown="onPointerDown($event, row)"
>
<td>{{ row.name }}</td>
<!-- other columns -->
</tr>
CSS:
.is-dragging-source {
opacity: 0.5;
}
9. Edge Cases and Limitations
- Dragged row is the last row:
fromNextIdd = null, initialtoIdd = null. Dragging to the end of data does not trigger sorting. - Mouse dragged outside content:
clampensuresinsertIndexdoes not go out of bounds. - Virtual scrolling window changes: The first rendered row and window data are re-fetched on every
pointermove, so accuracy is maintained after scrolling. - Row height must be fixed: The algorithm depends on
rowHeight; inconsistent actual row heights will cause deviation. - Tree structures not supported: This scheme targets flat lists.
- Coordinate calculation assumptions: The scroll container must have
position: relativeset and no significant borders or padding. If these exist, theoffsetTopandclientYconversion will have minor errors; compensation can be applied in actual projects as needed.
10. Summary
The core of this scheme can be condensed into four sentences:
- Anchor on stable Idd: The entire drag process uses only Idd to represent identity, completely摆脱 index dependency.
- Simplify content coordinate calculation with offsetTop: The first rendered row's
offsetTopdirectly gives the content offset. Subtract from the mouse's converted content coordinate, divide by row height, and round to get the window insertion index. - Indicator line directly reuses the same calculation result:
firstRowTopInContent + insertIndex * rowHeightis the indicator line'stop, requiring no additional query of the target row DOM. - Leverage buffer for precise insertion:
showGridDatanaturally provides the Idd of "the next item after the window end", enabling precise positioning even when dragging to the edge of the visible area.
This scheme not only solves the coordinate mapping problem under virtual scrolling but also considers interaction experience (ghost row, indicator line, auto-scrolling) and performance (rAF, transform, ResizeObserver). The business side only needs to operate the full data array in the onSort callback, without worrying about the complexity of virtual scrolling.