react-table-dnd is a React table component library with drag-and-drop reordering for both rows and columns. It achieves 60fps drag animations by manipulating the DOM directly (CSS transforms) instead of triggering React re-renders during drag. The library supports desktop (mouse/pen) and mobile (long-press + touch) with auto-scrolling near container edges.
src/
├── Components/
│ ├── TableContainer/ # Root provider — drag context, state machine, refs
│ │ ├── index.tsx # TableProvider with DragContext + useReducer
│ │ ├── useTable.tsx # TableContext & useTable hook
│ │ └── styles.tsx # Scoped styled-components (minimal resets)
│ ├── Draggable.tsx # Wraps any row/column cell — creates clone on pointerdown
│ ├── BodyRow.tsx # Row wrapper (Draggable type="row")
│ ├── ColumnCell.tsx # Column header cell (Draggable type="column")
│ ├── RowCell.tsx # Cell within a row — hides when its column is dragged
│ ├── TableHeader.tsx # Header container — syncs horizontal scroll with body
│ ├── TableBody.tsx # Body container — scrollable, hosts rows
│ ├── DragHandle.tsx # Optional grip icon to restrict drag start area
│ ├── utils.ts # Binary search for drop targets, range validation
│ └── index.ts # Public API exports
├── hooks/
│ ├── types.ts # TypeScript interfaces (DraggedState, Options, etc.)
│ ├── useDragContextEvents.tsx # Main orchestrator — drag start/move/end/cancel
│ ├── useAutoScroll.ts # Edge-zone auto-scroll with acceleration
│ └── useLongPress.ts # Mobile long-press detection + JS scrolling fallback
└── examples/ # 8+ demo files showing various configurations
TableContainer (Context Provider)
├── TableHeader
│ └── ColumnCell × N → Draggable (type="column")
│ └── optional DragHandle
└── TableBody
└── BodyRow × N → Draggable (type="row")
├── optional DragHandle
└── RowCell × N
Desktop: mousedown on a row/column → beginDrag() fires immediately.
Mobile: Touch on a row → 300ms long-press timer starts. During the wait, preventDefault() is called on every touchmove to block native scrolling. If the finger moves >8px, the timer is cancelled and JS-based scrolling takes over. If the finger stays still for 300ms, beginDrag() fires.
beginDrag() does the following:
- Walks up the DOM from the event target to find the
.draggableelement - Checks if a
DragHandleis present — if so, drag only starts from the handle - Captures the dragged element's size, position, and index
- Caches all row/column positions (via
computeRowItems()/computeColumnItems()) - Caches the container rect (used by auto-scroll — calculated once, never again)
- Dispatches
dragStartto the reducer — React renders the clone element - Positions the clone at the element's current viewport location
dragMove(clientX, clientY) is called on every pointer/touch move:
-
Clone follows finger/cursor — sets
transform: translate(x, y)on the clone element. This is a pure CSS write, no React re-render. -
Drop target detection — uses
binarySearchDropIndex()(O(log n)) to find which row/column the pointer is over. The search operates in absolute scroll-space coordinates so it works regardless of the current scroll position. -
Visual feedback — when the drop target changes,
applyShiftTransforms()runs viarequestAnimationFrame:- Iterates all rows/columns
- Applies
translateY()/translateX()to shift siblings out of the way - Positions the placeholder indicator at the drop gap
- Uses CSS transitions (
all 450ms cubic-bezier(0.2, 0, 0, 1)) for smooth animation
-
Auto-scroll — if the pointer is within 30px of the container edge,
startAutoScroll()is triggered (see Auto-Scroll below).
dragEnd() runs when the finger lifts or mouse releases:
- Captures
targetIndexandsourceIndexfrom refs - Saves the current scroll position (to restore after reflow)
- Clears all shift transforms and hides the placeholder
- Fires
onDragEnd({ sourceIndex, targetIndex, dragType })— the consumer reorders their data - Dispatches
dragEndto the reducer — React unmounts the clone - Restores scroll position (synchronously + in
requestAnimationFrameto survive React's reflow)
File: src/hooks/useAutoScroll.ts
When the pointer enters the 30px edge zone of the scrollable container, auto-scrolling begins.
startAutoScroll(speed, container, direction)
└── Sets flag, defers first tick via requestAnimationFrame
└── autoScroll(speed, ref, dir) [recursive rAF loop]
├── Check pointer against cached container rect
│ └── If pointer left edge zone → stop
├── ref.scrollTop += speed (or scrollLeft)
├── Check boundary (scrollTop >= maxScroll or <= 0) → stop
└── Schedule next tick: rAF(autoScroll(speed + decay))
| Decision | Why |
|---|---|
| Container rect cached once (at drag start) | getBoundingClientRect() forces synchronous layout. Calling it 60x/sec starved touch events on mobile. The container doesn't move during drag, so one read is enough. |
| First tick deferred (not synchronous) | Writing scrollTop inside a touch event handler causes Chrome Android to reclaim the touch sequence (touchcancel), killing all future touch/pointer events. Deferring to requestAnimationFrame avoids this. |
| Quadratic acceleration | decaySpeed += speed / 1000 each tick. Speed compounds: speed + decaySpeed feeds into the next tick. Starts slow, builds up naturally. No cap — the boundary check stops it. |
| Pointer check every frame | Uses cached rect + pointerRef (updated by drag handler). Pure in-memory comparison, no DOM reads. Stops auto-scroll when finger leaves edge zone. |
When the drag ends, clearShiftTransforms() removes CSS transforms from all rows. This causes a layout reflow that can shift scrollTop. The onDragEnd callback then triggers a React re-render (data reorder), causing another reflow. Scroll position is restored:
- Synchronously after
clearShiftTransforms() - In
requestAnimationFrameafter React re-renders
File: src/hooks/useLongPress.ts
Mobile drag-and-drop required solving several Chrome Android-specific issues.
Chrome Android evaluates touch-action at pointerdown time. Setting it dynamically (e.g., 300ms later after confirming a long press) is ignored. If touch-action is not none when the finger touches down, Chrome can fire touchcancel at any time (especially when programmatic scrolling via scrollTop occurs), killing all touch and pointer event delivery.
Solution: touch-action: none is set permanently on the body element (via useEffect on mount in useDragContextEvents). Since this disables native touch scrolling, useLongPress implements JS-based scrolling as a fallback when the long press is cancelled.
Writing ref.scrollTop inside a touchmove handler causes Chrome Android to reclaim the touch for native scrolling (even with touch-action: none set after pointerdown).
Solution: startAutoScroll() defers the first scroll tick to requestAnimationFrame instead of calling autoScroll() synchronously. The scroll write happens in a separate execution context, outside the touch handler.
Desktop uses window.pointermove for drag tracking — always reliable. Mobile originally used tableEl.touchmove { passive: false }, which blocks the compositor and can starve the main thread.
Solution: With touch-action: none set permanently, Chrome delivers pointermove and pointerup reliably for touch input. The window-level pointer event listeners handle touch the same way as mouse — dragMove() and dragEnd() fire from pointermove/pointerup for all pointer types. The touchmove handler in useLongPress provides a fallback.
With native scrolling disabled (touch-action: none), users need an alternative way to scroll the table when not dragging.
Solution: useLongPress detects scroll intent (finger moves >8px during the 300ms wait) and switches to a JS scroll mode:
touchstart → start 300ms timer
├── finger moves >8px → cancel timer, enter JS scroll mode
│ └── touchmove: body.scrollTop -= deltaY, body.scrollLeft -= deltaX
└── 300ms elapses → enter drag mode
└── touchmove: onDragMove(clientX, clientY)
| Operation | Cost | When |
|---|---|---|
computeRowItems() |
O(n) × getBoundingClientRect() |
Every dragMove call (recomputes fresh positions) |
applyShiftTransforms() |
O(n) DOM writes + 2 getBoundingClientRect() |
Only when drop target changes (via requestAnimationFrame) |
binarySearchDropIndex() |
O(log n) | Every dragMove call |
autoScroll tick |
~0ms (cached rect, pure scrollTop write) |
Every animation frame during auto-scroll |
dragMove pointer/clone update |
~0ms (style write + ref update) | Every pointer/touch move |
-
No React re-renders during drag — all visual updates are direct DOM manipulation (transforms, inline styles). React only renders on
dragStart(clone creation) anddragEnd(cleanup). -
Binary search for drop targets — O(log n) instead of iterating all elements. Items are cached in absolute scroll-space so they survive scroll position changes.
-
Event delegation — single
mousedown/touchstartlistener on the table element, not one per row. -
CSS transitions for shifts —
all 450ms cubic-bezier(0.2, 0, 0, 1)on sibling transforms. The browser handles the animation on the compositor thread. -
Deferred shift transforms —
applyShiftTransformsis batched viarequestAnimationFrame. Multiple drop index changes per frame collapse into one DOM update.
User drags row 5 to position 3:
1. pointerdown/touchstart
└── beginDrag() → dispatch("dragStart") → React renders clone
2. pointermove/touchmove (60x/sec)
└── dragMove(x, y)
├── clone.style.transform = translate(x, y) [direct DOM]
├── dropIndex = binarySearch(y, cachedItems) [O(log n)]
├── applyShiftTransforms(5, 3, "row") [rAF batched]
│ ├── row 3: translateY(+height) [shift down]
│ ├── row 4: translateY(+height) [shift down]
│ └── placeholder at row 3 position
└── startAutoScroll() if near edge
3. pointerup/touchend
└── dragEnd()
├── onDragEnd({ source: 5, target: 3, type: "row" })
│ └── consumer calls arrayMove(data, 5, 3) + setState
├── dispatch("dragEnd") → React unmounts clone
└── restore scroll position
interface DragEndResult {
sourceIndex: number;
targetIndex: number;
dragType: "row" | "column";
}
interface Options {
rowDragRange: { start?: number; end?: number };
columnDragRange: { start?: number; end?: number };
}
interface HookRefs {
tableRef: MutableRefObject<HTMLDivElement | null> | null;
bodyRef: MutableRefObject<HTMLDivElement | null> | null;
headerRef: MutableRefObject<HTMLDivElement | null> | null;
cloneRef: MutableRefObject<HTMLDivElement | null> | null;
placeholderRef: MutableRefObject<HTMLDivElement | null> | null;
}import { TableContainer, TableHeader, TableBody, BodyRow, ColumnCell, RowCell, DragHandle } from "react-table-dnd";
<TableContainer
onDragEnd={({ sourceIndex, targetIndex, dragType }) => {
if (dragType === "row") reorderRows(sourceIndex, targetIndex);
else reorderColumns(sourceIndex, targetIndex);
}}
options={{
rowDragRange: { start: 1 }, // freeze first row
columnDragRange: { start: 0, end: 5 }, // only first 5 columns draggable
}}
>
<TableHeader>
{columns.map((col, i) => (
<ColumnCell key={col.id} id={col.id} index={i} width={col.width}>
<DragHandle><GripIcon /></DragHandle>
{col.title}
</ColumnCell>
))}
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<BodyRow key={row.id} id={row.id} index={i}>
{columns.map((col, ci) => (
<RowCell key={col.id} index={ci}>{row[col.id]}</RowCell>
))}
</BodyRow>
))}
</TableBody>
</TableContainer>- Build:
npm run build→tsc -b && vite build - Output:
dist/index.es.js(ESM),dist/index.cjs.js(CJS),dist/index.d.ts(types) - Tree-shakeable:
sideEffects: false - Peer deps: React >=17.0.0
- Runtime deps:
classnames,styled-components