Row Drag-Drop Plugin
The Row Drag-Drop plugin lets users rearrange rows by dragging a grip handle
or using keyboard shortcuts, and — when opted in via dropZone — drag rows
between separate grids. It is a strict superset of the (now deprecated)
RowReorderPlugin: every existing intra-grid behaviour is preserved.
- Intra-grid — drag a row up or down to reorder, or use
Ctrl + ↑/↓. - Drag origin — choose whether drags start from the dedicated handle
column (
dragFrom: 'handle', default), from anywhere on the row (dragFrom: 'row'— handle column hidden), or from both (dragFrom: 'both'). Interactive descendants (buttons, inputs, links,[contenteditable]) inside the row are still respected and never start a drag. - Cross-grid — set a
dropZoneto allow dragging rows to another grid with the samedropZone. Supportsmoveandcopyoperations. - Multi-row — when the Selection plugin is loaded and
the dragged row is part of a multi-row selection, all selected rows are
dragged together and a count badge appears on the drag image. There is
no
selectionconfig option — the behaviour is automatic. - Cross-window — drag rows between separate browser windows or tabs
on the same origin. The payload travels via
dataTransfer, and a same-originBroadcastChannelcoordinates the source-side row removal foroperation: 'move'and the source-siderow-transferevent.
Installation
Section titled “Installation”import '@toolbox-web/grid/features/row-drag-drop';The legacy reorderRows feature key continues to work and forwards to the
same plugin instance.
Basic Usage
Section titled “Basic Usage”Enable the feature and a drag handle column appears automatically. Users can
drag rows to new positions or use Ctrl + ↑/↓ to move the focused row.
import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/row-drag-drop';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'priority', header: '#', type: 'number', width: 50 }, { field: 'task', header: 'Task' }, { field: 'status', header: 'Status' }, ], features: { rowDragDrop: { dragHandlePosition: 'left', enableKeyboard: true, animation: 'flip', }, },};
// Listen for row moves (intra-grid)grid.on('row-move', ({ fromIndex, toIndex }) => { console.log(`Moved row from ${fromIndex} to ${toIndex}`);});import '@toolbox-web/grid-react/features/row-drag-drop';import { DataGrid } from '@toolbox-web/grid-react';
function ReorderableTaskList({ tasks }) { return ( <DataGrid rows={tasks} columns={[ { field: 'priority', header: '#', type: 'number', width: 50 }, { field: 'task', header: 'Task' }, { field: 'status', header: 'Status' }, ]} rowDragDrop onRowMove={(detail) => console.log('Row moved:', detail)} /> );}<script setup>import '@toolbox-web/grid-vue/features/row-drag-drop';import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
const tasks = [ { priority: 1, task: 'Review PR', status: 'Pending' }, { priority: 2, task: 'Deploy staging', status: 'In Progress' },];</script>
<template> <TbwGrid :rows="tasks" row-drag-drop @row-move="(e) => console.log('Row moved:', e.detail)"> <TbwGridColumn field="priority" header="#" type="number" :width="50" /> <TbwGridColumn field="task" header="Task" /> <TbwGridColumn field="status" header="Status" /> </TbwGrid></template>// Feature import - enables the [rowDragDrop] inputimport { GridRowDragDropDirective } from '@toolbox-web/grid-angular/features/row-drag-drop';import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { ColumnConfig } from '@toolbox-web/grid';
@Component({ selector: 'app-task-list', imports: [Grid, GridRowDragDropDirective], template: ` <tbw-grid [rows]="rows" [columns]="columns" [rowDragDrop]="true" (rowMove)="onRowMove($event)" style="height: 400px; display: block;"> </tbw-grid> `,})export class TaskListComponent { rows = [ { priority: 1, task: 'Review PR', status: 'Pending' }, { priority: 2, task: 'Deploy staging', status: 'In Progress' }, ];
columns: ColumnConfig[] = [ { field: 'priority', header: '#', type: 'number', width: 50 }, { field: 'task', header: 'Task' }, { field: 'status', header: 'Status' }, ];
onRowMove(detail: any) { console.log('Row moved:', detail); }}Default Drag-Drop
Section titled “Default Drag-Drop”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/row-drag-drop';
const sampleData = [ { id: 1, name: 'Alice', department: 'Engineering', email: 'alice@example.com', priority: 1 }, { id: 2, name: 'Bob', department: 'Marketing', email: 'bob@example.com', priority: 2 }, { id: 3, name: 'Carol', department: 'Engineering', email: 'carol@example.com', priority: 3 }, { id: 4, name: 'David', department: 'Sales', email: 'david@example.com', priority: 4 }, { id: 5, name: 'Eve', department: 'Engineering', email: 'eve@example.com', priority: 5 },];const columns = [ { field: 'priority', header: '#', type: 'number', width: 50 }, { field: 'name', header: 'Name' }, { field: 'department', header: 'Department' }, { field: 'email', header: 'Email' },];
const grid = queryGrid('tbw-grid');
function rebuild(dragFrom = 'handle', dragHandlePosition = 'left', enableKeyboard = true, animation = 'flip') { grid.gridConfig = { columns, features: { rowDragDrop: { dragFrom, dragHandlePosition, enableKeyboard, animation: animation === 'false' ? false : animation } }, }; grid.rows = sampleData;}
rebuild();Drag the grip handle (☰) to reorder rows. Use Ctrl + ↑/↓ to move the
focused row with the keyboard.
Cancelable row-move
Section titled “Cancelable row-move”<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/row-drag-drop';
const sampleData = [ { id: 1, name: 'Alice', department: 'Engineering', email: 'alice@example.com', priority: 1 }, { id: 2, name: 'Bob', department: 'Marketing', email: 'bob@example.com', priority: 2 }, { id: 3, name: 'Carol', department: 'Engineering', email: 'carol@example.com', priority: 3 }, { id: 4, name: 'David', department: 'Sales', email: 'david@example.com', priority: 4 }, { id: 5, name: 'Eve', department: 'Engineering', email: 'eve@example.com', priority: 5 },];const columns = [ { field: 'priority', header: '#', type: 'number', width: 50 }, { field: 'name', header: 'Name' }, { field: 'department', header: 'Department' }, { field: 'email', header: 'Email' },];
const status = document.getElementById('row-drag-drop-cancelable-status');
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns, features: { rowDragDrop: true },};grid.rows = sampleData;
// Prevent moving Bobgrid.on('row-move', (detail, e) => { if (detail.row.name === 'Bob') { e.preventDefault(); status.textContent = '❌ Cannot move Bob!'; status.style.background = 'var(--tbw-color-danger-light)'; } else { status.textContent = `✓ Moved ${detail.row.name} from index ${detail.fromIndex} to ${detail.toIndex}`; status.style.background = 'var(--tbw-color-success-light)'; }});Cancel row moves by calling event.preventDefault() in the row-move
handler.
Cross-grid transfer
Section titled “Cross-grid transfer”The grids below share dropZone: 'employees' with operation: 'move'.
Drag any row from the left grid to the right grid (or back).
Available employees
Selected employees
import '@toolbox-web/grid';import { queryGrid, type ColumnConfig } from '@toolbox-web/grid';import '@toolbox-web/grid/features/row-drag-drop';import '@toolbox-web/grid/features/selection';
const columns: ColumnConfig<any>[] = [ { field: 'id', header: 'ID', type: 'number', width: 60 }, { field: 'name', header: 'Name' }, { field: 'department', header: 'Department' },];
const source = queryGrid('tbw-grid#rdd-source');const target = queryGrid('tbw-grid#rdd-target');
// Enable Selection on the source grid so multi-row drag is automatic when// the dragged row is part of a multi-row selection. RowDragDrop has no// `selection` config — the behaviour is driven entirely by the presence of// a multi-row selection at dragstart.source.gridConfig = { columns, features: { rowDragDrop: { dropZone: 'employees', operation: 'move' }, selection: 'row', },};source.rows = [ { id: 1, name: 'Alice', department: 'Engineering' }, { id: 2, name: 'Bob', department: 'Marketing' }, { id: 3, name: 'Carol', department: 'Engineering' }, { id: 4, name: 'David', department: 'Sales' }, { id: 5, name: 'Eve', department: 'Engineering' },];
target.gridConfig = { columns, features: { rowDragDrop: { dropZone: 'employees', operation: 'move' } },};target.rows = [];<tbw-grid style="height: 320px; display: block;"></tbw-grid> </div> <div> <h4 style="margin: 0 0 8px 0;">Selected employees</h4> <tbw-grid style="height: 320px; display: block;"></tbw-grid>Cross-window transfer
Section titled “Cross-window transfer”Grids in different browser windows (or tabs) on the same origin can also
exchange rows. The button in the demo below opens a popout window that
hosts a target grid joined to the same dropZone. Drag any row from the
left grid into the popout — the row is moved across windows, and the
source grid’s row-transfer event fires once the popout confirms the drop.
Available employees
Click the button to open the target grid in a new browser window. Then drag rows from this grid into the popout. Both grids must run on the same origin; popup blockers may interfere.
<tbw-grid style="height: 320px; display: block;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/row-drag-drop';
const source = queryGrid('tbw-grid');const status = document.querySelector('#rdd-cross-window-status');const btn = document.querySelector('#rdd-popout-btn');
source.gridConfig = { columns: [ { field: 'id', header: 'ID', type: 'number', width: 60 }, { field: 'name', header: 'Name' }, { field: 'department', header: 'Department' }, ], features: { rowDragDrop: { dropZone: 'cross-window-employees', operation: 'move' }, },};source.rows = [ { id: 1, name: 'Alice', department: 'Engineering' }, { id: 2, name: 'Bob', department: 'Marketing' }, { id: 3, name: 'Carol', department: 'Engineering' }, { id: 4, name: 'David', department: 'Sales' }, { id: 5, name: 'Eve', department: 'Engineering' },];
source.addEventListener('row-transfer', ((e: Event) => { const detail = (e).detail; status.textContent = `Sent ${detail.rows.length} row(s) to ${detail.toGridId}.`;}));
btn.addEventListener('click', () => { const w = window.open( '/grid/row-drag-drop-popout/', 'tbw-rdd-popout', 'width=520,height=480,resizable=yes,scrollbars=yes', ); if (!w) { status.textContent = 'Popup blocked. Please allow popups for this site and try again.'; } else { status.textContent = 'Popout opened. Drag rows from above into the new window.'; }});The popout page itself is a tiny standalone host that registers the same
dropZone (pages/grid/row-drag-drop-popout.astro).
Under the hood the plugin sets a custom MIME type on dataTransfer and
opens a BroadcastChannel('tbw-row-drag-drop'). The target window decodes
the payload from dataTransfer and broadcasts a confirmation; the source
window’s plugin instance listens on the same channel and — on move —
removes the transferred rows and emits row-transfer locally.
Custom row shapes. Rows are round-tripped through JSON.parse(JSON.stringify(row))
by default. If your rows contain non-JSON values (functions, Date, Map,
etc.), provide serializeRow / deserializeRow so the target reconstructs
an equivalent object:
features: { rowDragDrop: { dropZone: 'employees', serializeRow: (row) => ({ ...row, hiredAt: row.hiredAt.toISOString() }), deserializeRow: (raw) => ({ ...raw, hiredAt: new Date(raw.hiredAt) }), },}Caveats.
- Cross-window coordination requires
BroadcastChannel; in environments without it (very old browsers, sandboxed iframes), only the target receives rows and the source is left untouched. - Both windows must be on the same origin —
BroadcastChannelis origin-scoped. - The source’s
row-drag-endmay fire withaccepted: falsebefore the remote confirmation arrives. Treat therow-transferevent as the authoritative success signal for cross-window transfers.
Configuration
Section titled “Configuration”See RowDragDropConfig for the full list
of options and defaults. Key options:
| Option | Type | Description |
|---|---|---|
dropZone | string | Opt-in identifier for cross-grid drops. Grids with the same value accept each other. |
operation | 'move' | 'copy' | Whether the source grid keeps the rows after a successful cross-grid drop. |
canDrop | (payload, targetIndex) => boolean | Reject specific drops on the target grid. Called synchronously during dragover and at drop time. |
canDrag | (row, index) => boolean | Reject drags from the source. Called once at dragstart with the originating row. |
serializeRow | (row) => unknown | Override the JSON shape used in the cross-window fallback payload. |
deserializeRow | (serialized) => row | Inverse of serializeRow. |
enableKeyboard | boolean | Keep keyboard reorder (Ctrl + ↑/↓) — defaults to true. |
dragFrom | 'handle' | 'row' | 'both' | Where a drag can be initiated. 'handle' (default) keeps the handle column; 'row' hides the handle and lets users drag anywhere on the row; 'both' allows either. Interactive descendants (button, input, select, textarea, a[href], [contenteditable]) never start a drag. |
showDragHandle | boolean | Render the drag-handle column. Defaults to true for dragFrom: 'handle' | 'both' and false for dragFrom: 'row'. Set explicitly to override. |
dragHandlePosition | 'left' | 'right' | Pin the handle column to either side. |
dragHandleWidth | number | Pixel width of the handle column. |
animation | 'flip' | false | FLIP animation on intra-grid reorder. |
autoScroll | boolean | { edgeSize?: number; speed?: number; maxSpeed?: number } | Tune the auto-scroll behaviour during drag. |
Events
Section titled “Events”| Event | Detail | Cancelable | When |
|---|---|---|---|
row-move | RowMoveDetail | Yes | Intra-grid reorder committed (back-compat). |
row-drag-start | RowDragStartDetail | Yes | A drag begins. |
row-drag-end | RowDragEndDetail | No | The drag ends, regardless of whether a drop succeeded. |
row-drop | RowDropDetail | Yes | Rows are dropped on this grid from another grid. |
row-transfer | RowTransferDetail | No | Successful cross-grid transfer (fired on both grids). |
Keyboard Shortcuts
Section titled “Keyboard Shortcuts”| Key | Action |
|---|---|
Ctrl + ↑ | Move focused row up |
Ctrl + ↓ | Move focused row down |
Styling
Section titled “Styling”The plugin adds these CSS classes you can customise:
| Class | Description |
|---|---|
.dg-row-drag-handle | The drag handle element |
.dg-row-drag-handle:hover | Handle hover state |
.data-grid-row.dragging | Row currently being dragged |
.data-grid-row.drop-target | Row being hovered as drop target |
.data-grid-row.drop-before | Drop indicator showing insertion above |
.data-grid-row.drop-after | Drop indicator showing insertion below |
.tbw-grid--drag-source | The source grid during a drag (whole-grid state) |
.tbw-grid--drop-target-active | The target grid during a valid dragover |
.tbw-grid--drop-target-rejected | The target grid during a canDrop-rejected drag |
.tbw-grid--auto-scrolling | A grid currently auto-scrolling during a drag |
Custom Handle Icon
Section titled “Custom Handle Icon”Override the default grip icon via CSS:
.dg-row-drag-handle::before { content: '⋮⋮'; /* Double vertical ellipsis */}- Row identification — currently uses processed/visual row indices
(
data-rowattribute andpayload.rowIndices), notgetRowId(). Same-window cross-grid drops also recover live object references via the internalWeakRefregistry; primitive row values fall through to the JSON payload. - Data mutation — the plugin reorders
grid.rowsin place. Therow-moveevent provides the updated array. - Virtualization — works with virtualized grids; only visible rows are rendered but logical indices are preserved.
- Keyboard debounce — rapid keyboard moves are debounced (150 ms) to prevent excessive rerenders.
- Drag image — when a drag starts the plugin clones the source row and uses it as the HTML5 drag image so the user sees the full row content follow the cursor (instead of just the handle cell). The browser applies its standard translucency to the snapshot — this is a native effect that cannot be disabled.
- Cross-window fallback — when the live row reference cannot be
recovered from the same-window registry (e.g. drop into a different
window), the row payload is decoded from
dataTransferJSON viaserializeRow/deserializeRow. The source window completes its half of the transfer (row removal onmove,row-transferemit) when it receives the confirmation message on thetbw-row-drag-dropBroadcastChannel.
Migration from RowReorderPlugin
Section titled “Migration from RowReorderPlugin”RowReorderPlugin is now an alias for RowDragDropPlugin. Existing code
keeps working until V3 — both reorderRows and rowDragDrop feature keys
resolve to the same plugin instance, and merging conflicting configs across
the two keys will throw at attach time. To migrate:
import { RowReorderPlugin } from '@toolbox-web/grid/plugins/reorder-rows';import { RowDragDropPlugin } from '@toolbox-web/grid/plugins/row-drag-drop';
new RowReorderPlugin(cfg);new RowDragDropPlugin(cfg);The legacy canMove callback continues to work — it is mapped internally to
canDrop with a synthesised intra-grid payload.
See Also
Section titled “See Also”- Column Reorder — Drag-to-reorder columns
- Selection — Row and cell selection (drives multi-row drag)