TL;DR: dnd kit is the modern drag-and-drop toolkit for React 18. It supports pointer, mouse, touch, and keyboard sensors, works with virtual lists, and replaces the unmaintained react-beautiful-dnd. More setup than its predecessor, but far more flexible. Official docs: dndkit.com.
What is dnd kit?
dnd kit is the modern replacement for react-beautiful-dnd (now unmaintained). It's built from the ground up for React 18, supports pointer, mouse, touch, and keyboard sensors, and works with virtual lists. The API is lower-level than its predecessor - more flexible, but requires more setup for common use cases.
When to use it
Use dnd kit for any drag-and-drop interaction in React:
- Sortable lists and kanban boards
- File reordering
- Dashboard widget rearrangement
- Multi-container dragging (drag between columns)
If you were using react-beautiful-dnd and it's now breaking on React 18, dnd kit is the migration path.
Key Features
- Sensors - Pointer (mouse/touch), Keyboard, and custom sensors
@dnd-kit/sortable- higher-level package for sortable lists with smooth animations- Multi-container - drag items between separate droppable areas
- Collision detection - pluggable algorithms (closest center, closest corners, pointer within)
- Accessibility - screen reader announcements built in
- Virtual list support - works with TanStack Virtual
Installation
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
What are the three pieces?
The API looks big because the docs list every hook. For a first sortable list you need three things, and each has one job.
| Piece | Job | What it does not do |
|---|---|---|
DndContext | Listens for drags through its sensors and reports onDragEnd | Move anything in your data |
SortableContext | Knows the current order of ids so neighbours can shuffle out of the way | Know when the order changed |
useSortable | Gives one item its ref, its handlers and its transform | Render a handle for you |
The last column is where first-time users lose an hour. dnd kit is headless: it tells you that item a was dropped over item c, and you decide what that means for your array. If the item snaps back after you let go, your onDragEnd didn't call setState. If it never moves under the pointer, the transform from useSortable isn't reaching the element's style. And if items jump around, the ids you pass to SortableContext aren't in the same order as the elements you render.
Sortable List
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useState } from 'react'
function SortableItem({ id, label }: { id: string; label: string }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}
className="p-3 bg-white border rounded-lg cursor-grab active:cursor-grabbing"
>
{label}
</div>
)
}
function SortableList() {
const [items, setItems] = useState([
{ id: '1', label: 'Item 1' },
{ id: '2', label: 'Item 2' },
{ id: '3', label: 'Item 3' },
])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (over && active.id !== over.id) {
setItems(items => {
const oldIndex = items.findIndex(i => i.id === active.id)
const newIndex = items.findIndex(i => i.id === over.id)
return arrayMove(items, oldIndex, newIndex)
})
}
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
{items.map(item => (
<SortableItem key={item.id} id={item.id} label={item.label} />
))}
</div>
</SortableContext>
</DndContext>
)
}
TypeScript Tips
Type your drag data for cross-container scenarios:
interface DragData {
type: 'task'
columnId: string
}
// Access in event handlers:
function handleDragEnd(event: DragEndEvent) {
const data = event.active.data.current as DragData | undefined
if (data?.type === 'task') { /* ... */ }
}
Persisting the new order
arrayMove updates React state and nothing else. Reload the page and the order is gone, which is the point most sortable demos stop at and most real features start at.
The pattern that holds up is an optimistic update with a rollback:
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
const previous = items
const next = arrayMove(items, items.findIndex((i) => i.id === active.id), items.findIndex((i) => i.id === over.id))
setItems(next)
saveOrder(next.map((i) => i.id)).catch(() => {
setItems(previous)
})
}
Keep previous before you mutate. Without it a failed request leaves the UI showing an order the server rejected, and the user finds out on the next reload.
How you store position matters more than it looks. Writing an integer index onto every row means one drag rewrites the whole list, which is fine for twenty items and a problem at two thousand. The alternative is a fractional rank: give each row a float, and on drop set the moved row to the midpoint between its new neighbours. One row changes per drag regardless of list length.
Floats do run out of precision after enough drags into the same gap, so rebalance the column on a schedule rather than pretending it cannot happen. If you would rather not own that, fractional-indexing produces sortable strings instead and sidesteps the precision question entirely.
Common Gotchas
idmust be a string or number - don't use object references as IDsuseSortablemust be insideSortableContextwhich must be insideDndContext- Drag overlay - for complex items, use
DragOverlayto show a custom dragging preview that follows the pointer, instead of the original item - Touch conflict with scroll - use
activationConstraintonPointerSensorto distinguish scroll intent from drag intent on mobile
Building a kanban board with multiple columns
The single sortable list is the easy case. Real apps usually need items that move between containers, like a kanban board where cards jump from "To Do" to "In Progress". dnd kit handles this, but you have to do a bit of bookkeeping yourself.
The trick is attaching metadata to each draggable and each droppable through the data option. When onDragOver or onDragEnd fires, you read that metadata to figure out which column the card came from and which column it landed in. dnd kit won't move the item across arrays for you - it only tells you what happened. You own the state update.
const { setNodeRef } = useDroppable({
id: columnId,
data: { type: 'column', columnId },
})
A common mistake is forgetting that an empty column has no items to collide with. If a column can be empty, register the column container itself as a droppable so cards can still land there. Without that, dropping into an empty column does nothing and users get frustrated fast.
Accessibility is the real selling point
Most drag-and-drop on the web is mouse-only, which locks out keyboard and screen reader users entirely. dnd kit ships keyboard support out of the box through the KeyboardSensor. Users tab to an item, press space to pick it up, move with arrow keys, and press space again to drop. That alone covers a huge accessibility gap.
It also fires live region announcements as items move, so a screen reader can say things like "Item 2 moved to position 3 of 5". You can customize these strings through the announcements prop on DndContext. If you ship drag-and-drop in a product that has to meet WCAG, this is the library that gets you there without writing the ARIA plumbing yourself.
Performance on long and virtual lists
dnd kit avoids re-rendering the whole list during a drag. It tracks the active item and applies transforms only where needed. For lists in the low thousands you'll be fine without virtualization.
Once you cross into very large lists, pair it with a windowing library. The catch is that virtualization unmounts off-screen items, so the drop target you're aiming for might not exist in the DOM yet. You handle this with a custom collision detection function plus auto-scroll near the viewport edges. It's more work, but it's possible, and the official docs include a worked example. I'd say most lists never need this, so measure first before reaching for it.
Why does a list that works with a mouse fail on a phone?
Because on a touch screen the browser gets the first say. A finger that moves is a scroll unless the element says otherwise, and the pointer events dnd kit listens to can't cancel that after the fact. The dnd kit docs put it plainly: for Pointer events there's no way to prevent the default browser behaviour from the listeners, and touch-action: none is the only reliable way to stop scrolling. They also suggest applying it to a drag handle rather than the whole item, so the rest of the row still scrolls.
That gives you two workable recipes, and a distance constraint on its own is neither of them.
- A handle with
touch-action: none. The handle owns the gesture, the row stays scrollable. Use thePointerSensororMouseSensor, with a smalldistanceso a click doesn't count as a drag. - Whole-row dragging with
TouchSensor. Settouch-action: manipulationon the row (the docs' recommendation for this sensor) and give the sensor adelayplus atolerance, so a press-and-hold starts a drag but a swipe still scrolls.
The delay and distance constraints are mutually exclusive on a given sensor. tolerance only means something next to delay: how far the finger may drift during the hold before the drag is abandoned.
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
Here's a row built around a handle. setActivatorNodeRef tells dnd kit that the button, not the li, starts the drag, which also lets focus return to the handle after a keyboard drop.
function Row({ task }: { task: { id: string; label: string } }) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } =
useSortable({ id: task.id })
return (
<li ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }}>
<button
type="button"
ref={setActivatorNodeRef}
aria-label={`Reorder ${task.label}`}
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<span aria-hidden="true">::</span>
</button>
{task.label}
</li>
)
}
One honest limit on all of this: I don't have a phone farm. The touch-action guidance comes from the dnd kit documentation, and the code below was checked with the TypeScript compiler and a keyboard test, not on a physical touch screen. Test on a real device before you ship, because emulators fake touch behaviour badly.
How do you make the screen reader announcements say something useful?
The defaults name ids: "Picked up draggable item 3". An id is a database key, not a thing a person can recognise. The accessibility prop on DndContext lets you replace both the announcements and the instruction text that's attached to each handle through aria-describedby. The docs recommend position-based wording over index-based wording, so a person hears where the item sits in the list.
const labelOf = (id: string | number) => tasks.find((t) => t.id === id)?.label ?? String(id)
const positionOf = (id: string | number) => tasks.findIndex((t) => t.id === id) + 1
const announcements: Announcements = {
onDragStart({ active }) {
return `Picked up ${labelOf(active.id)}, position ${positionOf(active.id)} of ${tasks.length}.`
},
onDragOver({ active, over }) {
if (!over || over.id === active.id) {
return undefined
}
return `${labelOf(active.id)} is now over position ${positionOf(over.id)} of ${tasks.length}.`
},
onDragEnd({ active, over }) {
return over
? `Dropped ${labelOf(active.id)} at position ${positionOf(over.id)} of ${tasks.length}.`
: `Dropped ${labelOf(active.id)}. The order did not change.`
},
onDragCancel({ active }) {
return `Cancelled. ${labelOf(active.id)} is back at position ${positionOf(active.id)}.`
},
}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
accessibility={{
announcements,
screenReaderInstructions: {
draggable: 'Press space to pick up a task. Use the up and down arrow keys to move it, space to drop, escape to cancel.',
},
}}
>
Why the early return in onDragOver? Because dnd kit reports the item as being over itself the instant it's picked up. Run under Vitest 5 and jsdom with stubbed layout rectangles, this list showed it: without the guard the "picked up" message was replaced by "is now over position 1" before anyone could hear it. With the guard, a keyboard drag read: "Picked up Write draft, position 1 of 3", then "Write draft is now over position 2 of 3" after one arrow press, then "Dropped Write draft at position 2 of 3". The handle renders role="button", aria-roledescription="sortable" and an aria-describedby pointing at your instruction text, the array ended up reordered, and focus came back to the handle afterwards.
The keyboard controls are fixed by the library: space or enter to pick up and drop, arrows to move, escape to cancel. Your instruction string has to match them, which is why it's worth writing it by hand rather than trusting a translation.
Which drag-and-drop library should you use?
Three libraries cover most React projects in 2026, and they aren't interchangeable. The honest split is by shape of the problem.
| dnd kit | @hello-pangea/dnd | Pragmatic drag and drop | |
|---|---|---|---|
| Built on | its own pointer, touch and keyboard sensors | its own engine, forked from react-beautiful-dnd | the browser's native drag and drop |
| Best at | free-form layouts, custom collision, grids, canvases | vertical and horizontal lists, kanban columns | performance-sensitive boards, file drops from the desktop, any framework |
| Keyboard and screen reader | built in, customisable | built in, strong defaults | you build it (Atlassian documents accessibility guidelines) |
| API size | many small hooks | three components | functions you wire up yourself |
| Status | @dnd-kit/core last published December 2024; successor @dnd-kit/react still 0.x | maintained fork | maintained by Atlassian |
Pick @hello-pangea/dnd for a plain list or board, because its defaults ask for the least code. Pick dnd kit when the layout isn't a list. Pick Pragmatic when you need native drag semantics, such as dragging files in from the desktop, or when your boards are huge. See the Hello Pangea DnD page for that library on its own.
Does one of these matter more than the others for your risk? The maintenance column. @dnd-kit/core hasn't had a release in months, and @dnd-kit/react, the rewrite, was at version 0.5.0 on npm when this page was updated. That's not a reason to avoid it. It's a reason to wrap your drag-and-drop in one component so a later swap touches one file.
How do you migrate from react-beautiful-dnd?
First check whether you need to. The original react-beautiful-dnd repository is archived and the npm package is marked deprecated. If your only reason to move is React 18 or 19 support, the cheaper fix is @hello-pangea/dnd, which keeps the same component names and props, so migration is a dependency swap and an import find-and-replace. Rewriting to dnd kit makes sense when you need what react-beautiful-dnd never offered: grids, nested free-form layouts, custom collision.
If you do rewrite, here's the vocabulary map:
| react-beautiful-dnd | dnd kit |
|---|---|
DragDropContext | DndContext |
Droppable | SortableContext for a sortable list, useDroppable for a plain target |
Draggable plus provided.innerRef | useSortable plus setNodeRef |
provided.draggableProps and dragHandleProps | style from transform, plus attributes and listeners |
snapshot.isDragging | isDragging from useSortable |
onDragEnd(result) with source.index and destination.index | onDragEnd({ active, over }) with ids |
The last row is the one that bites. react-beautiful-dnd hands you indexes, dnd kit hands you ids, and you look the indexes up yourself:
function handleDragEnd({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) {
return
}
setItems((current) =>
arrayMove(
current,
current.findIndex((item) => item.id === active.id),
current.findIndex((item) => item.id === over.id),
),
)
}
A destination of null in the old API becomes over === null here. Handle it first, as above, or a drop outside the list throws.
Related
- Framer Motion - add
layoutprop to list items for smooth animated reordering as dnd kit updates array order - TanStack Virtual - virtualised sortable lists require custom collision detection; dnd kit supports this with the
DndContextAPI