Drag-and-Drop
Reorderable lists with mouse, touch, and keyboard support. The browser's native drag API looks tempting but breaks on mobile and inside scroll containers. Use dnd-kit instead.
The problem I keep seeing
Drag-and-drop feels simple until you need it to actually work. Developers reach for the browser's native draggable attribute and onDragOver/onDrop events, which work for basic mouse drag on desktop. But they break on iOS entirely (Safari ignores the touch events), produce an ugly browser ghost image, give no drop indicator, and clip when the parent has overflow: hidden. Rolling your own from raw pointer events is worse.
The real requirement is usually: reorder items in a list, show a drag preview that doesn't clip, work on touch and with keyboard, sync the new order to the server, and roll back if the mutation fails.
Naive approach
Native HTML drag events. Works for mouse on desktop; fails everywhere else.
First improvement
dnd-kit is the right library for React DnD in 2024+. It abstracts sensors (pointer, touch, keyboard) behind a composable API, handles ARIA announcements for accessibility, and gives you CSS transforms for smooth movement instead of browser ghost images. The useSortable hook wraps each item; arrayMove handles the reorder math; SortableContext coordinates the list.
Remaining issues
- No DragOverlay: The dragged item moves in-place using CSS transforms. If the parent has
overflow: hidden, the dragged item clips.DragOverlayrenders the preview in a portal at the document root, solving this. - No optimistic server sync: Calling
onReorderwhich triggers a React Query mutation will cause the list to revert to server state until the mutation settles. Store local order inuseStateand update it immediately; rollback on error. - Clicks on interactive children: Without an
activationConstraintonPointerSensor, a simple click on a button inside the row will trigger the drag. Adddistance: 8so a drag only starts after 8px of movement.
Production pattern
Combine DragOverlay for a portal-rendered preview, PointerSensor with activation constraint, KeyboardSensor for accessibility, local useState for optimistic reorder, and mutation rollback on error.
Database ordering
"a0" that sorts lexicographically) so any insert is a single-row update. Libraries like fractional-indexing handle this.When I use this
- Kanban boards: Card reorder within a column and cross-column moves (dnd-kit handles both with multiple
SortableContextinstances). - Block editors: Notion-style block reordering where any content block can be dragged above or below another.
- Ordered lists with user preference: Saved search filters, dashboard widgets, playlist tracks.
- Skip when: Items are sorted by a system criterion (date, score) the user can't override. Don't add drag interaction for its own sake.
Gotchas
- Attach listeners to a handle, not the whole row. If you spread
listenerson the<li>, every click-inside becomes a potential drag start. Use a<button>with a grip icon as the handle. - activationConstraint is not optional. Without
distance: 8, clicking a button inside the row triggers the drag and swallows the click event. This causes confusing UX where buttons appear to not respond. - DragOverlay vs in-place transform. The in-place CSS transform approach is fine for simple cases. Use
DragOverlaywhenever the list sits inside a scrollable container, a modal, or anything withoverflow: hidden. - dnd-kit + virtualized lists. There is a known incompatibility between
@dnd-kit/sortableand TanStack Virtual: the virtualizer unmounts items that scroll out of view, which confuses the drop position calculation. The workaround is to overscan heavily during a drag (render more items outside the viewport) and reset overscan on drag end. - Rollback needs a snapshot. Capture
itemsbefore the optimistic update (e.g.const prev = items) so you have something to roll back to inonError. Closures capture stale state; use a ref if the mutation is async.
Optimistic Updates → · Virtualized Lists → · Used in: Notion editor → · All patterns