Drag-and-Drop in React with dnd kit: Sortable, Multi-Container
Build sortable lists and multi-container drag-and-drop in React using dnd kit. Covers SortableContext, sensors, collision detection, and real-world Kanban patterns.
Why dnd kit Won Over Every Other React DnD Library
Honestly, most drag-and-drop libraries in the React ecosystem were designed around a time when accessibility and pointer events were an afterthought. react-beautiful-dnd was gorgeous but Atlassian archived it in 2022. react-dnd is still alive but its API feels like you're wrestling with abstractions instead of building things.
dnd kit (@dnd-kit/core v6.x) came in with a different philosophy: composable, headless, and accessibility-first out of the box. It ships keyboard navigation, screen-reader announcements, and ARIA attributes without you doing anything extra. That alone is worth the switch.
The bundle is also tiny. @dnd-kit/core weighs around 10.6 kB gzipped. Add @dnd-kit/sortable on top and you're still under 16 kB total. For a feature this rich, that's genuinely impressive. You're not paying the performance tax you'd expect.
Installing dnd kit and Understanding the Core Concepts
Start with two packages. The core handles the drag engine — sensors, collision algorithms, and context. The sortable package layers on top with the higher-level primitives you'll actually use day to day.
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesThree concepts you need to internalize before writing any code. First, DndContext is the outer wrapper — everything lives inside it. Second, draggables are elements that can be picked up, and droppables are zones that accept them. Third, sensors are how dnd kit detects user intent — pointer events, keyboard input, or touch. You configure sensors on the context, and they handle the rest.
The useSensor and useSensors hooks let you compose multiple input methods together. A typical setup pairs PointerSensor with KeyboardSensor so both mouse and keyboard users get a working experience. You can also configure activation constraints — for example, requiring 8px of movement before a drag starts, which prevents accidental drags on click.
Building a Basic Sortable List with SortableContext
A sortable list is the most common use case. You've got an ordered array of items and you want the user to reorder them by dragging. dnd kit's SortableContext plus useSortable handles this cleanly.
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useState } from 'react';
type Item = { id: string; label: string };
function SortableItem({ id, label }: Item) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="flex items-center gap-3 px-4 py-3 mb-2 rounded-lg bg-white border border-gray-200 shadow-sm cursor-grab active:cursor-grabbing"
>
<span className="text-gray-400">⠿</span>
<span className="text-sm font-medium text-gray-700">{label}</span>
</div>
);
}
export function SortableList() {
const [items, setItems] = useState<Item[]>([
{ id: '1', label: 'Design mockups' },
{ id: '2', label: 'Set up API routes' },
{ id: '3', label: 'Write unit tests' },
{ id: '4', label: 'Deploy to staging' },
]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((prev) => {
const oldIndex = prev.findIndex((i) => i.id === active.id);
const newIndex = prev.findIndex((i) => i.id === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((item) => (
<SortableItem key={item.id} {...item} />
))}
</SortableContext>
</DndContext>
);
}Notice arrayMove — that's a utility from @dnd-kit/sortable that returns a new array with the items swapped. Never mutate the original. The closestCenter collision detection algorithm works well for lists because it matches the dragged item against whichever droppable center is nearest. For grids you'd switch to closestCorners or rectIntersection.
Multi-Container Drag-and-Drop: Kanban Board Pattern
Multi-container is where things get genuinely interesting — and where a lot of tutorials fall short. A Kanban board has multiple columns, and items need to move both within and between columns. dnd kit handles this, but you have to think about your data model carefully.
The key insight is that your DndContext wraps everything, and you have one SortableContext per column. When onDragOver fires (not onDragEnd), that's when you update state to move an item between containers. If you wait for onDragEnd, you'll get a jarring snap-back before the item settles in its new column.
Here's the minimal data shape that works well. Keep a columnOrder array and a columns record mapping column IDs to their item arrays. On onDragOver, detect if the active item's container differs from the over item's container. If it does, remove from source and insert at the target position. On onDragEnd, just handle reordering within the same column if the positions changed.
One gotcha: when dragging over an empty column, there's no item to collide with. You need to make the droppable column itself a valid drop target using useDroppable. Check if over.id matches a column ID directly, and if so, append to that column. This is the part most tutorials skip entirely. Without it, users can't drag into empty columns and they'll file bug reports immediately.
Collision Detection Algorithms: Choosing the Right One
dnd kit ships four built-in collision detection algorithms and you can write custom ones. The choice actually matters for UX. closestCenter measures distance from drag center to each droppable center — great for sortable lists. closestCorners measures corner-to-corner distance — better for grid layouts where you want snappier feedback.
rectIntersection triggers when the dragged element's bounding box overlaps a droppable's bounding box by any amount. This is more forgiving and works well for large drop zones like Kanban columns. pointerWithin uses the pointer position itself rather than the draggable's rect — useful when your drag overlay is much larger than the actual interaction target.
For a Kanban board, a custom strategy often works best. You'd use rectIntersection for column targets and closestCenter for item targets within columns. dnd kit lets you return early from a custom collision function, so you can check columns first and fall back to items. It's maybe 15 lines of code and completely eliminates the common "item jumping to wrong column" bug.
If you're building something with glassmorphism card styles — frosted panels with rgba(255,255,255,0.15) backgrounds — rectIntersection tends to feel more natural because users expect larger translucent areas to be generous drop targets.
Drag Overlay: Keeping Performance Solid During Drag
Here's the thing: rendering a placeholder where the item was and a floating clone where it's being dragged is the pattern that feels right to users. dnd kit's DragOverlay component handles the floating clone part. It renders into a portal outside your normal DOM tree, so there are no stacking context or overflow issues.
Wrap your overlay content in DragOverlay and conditionally render based on activeId. The overlay gets its own render, completely separate from the original item in the list. This means you can style it differently — add a box shadow, scale it up slightly with transform: scale(1.02), give it a slightly elevated z-index. It communicates "this is the thing you're holding" without any extra work.
import { DragOverlay } from '@dnd-kit/core';
// Inside your component:
<DragOverlay>
{activeId ? (
<div className="px-4 py-3 rounded-lg bg-white border-2 border-blue-400 shadow-xl scale-105 cursor-grabbing">
{items.find((i) => i.id === activeId)?.label}
</div>
) : null}
</DragOverlay>Performance-wise, you'll want to memoize your item components with React.memo. During a drag, dnd kit updates state on every pointer move event. Without memoization, every item re-renders on every frame. With it, only the actively dragged item and the drop target re-render. That's the difference between 60fps and a choppy mess on a long list. If you're already thinking about performance patterns, the React performance guide has solid detail on when memo actually helps.
Accessibility and Keyboard Navigation Out of the Box
Most drag-and-drop libraries treat keyboard accessibility as a checkbox afterthought. dnd kit actually thought about it. With KeyboardSensor and sortableKeyboardCoordinates, your list is fully operable with keyboard alone — Space to pick up, arrow keys to move, Space again to drop, Escape to cancel.
Screen reader announcements are also built in. DndContext accepts accessibility prop where you can pass announcements — functions that return strings for when dragging starts, when an item is over a droppable, and when dragging ends. The defaults are sensible English strings, but you'll want to customize them for your domain and for i18n.
Why does this matter practically? In many markets, WCAG 2.1 AA compliance is legally required. Beyond legal reasons — about 15% of users have some form of motor or visual impairment that affects mouse use. Making your Kanban board keyboard-navigable isn't charity, it's just building something that works for everyone.
Pair this kind of interaction work with thoughtful toast notifications to give users feedback when a drag operation completes — especially useful when moving items between remote-synced containers where the result might take a moment to confirm.
Styling Drag States with Tailwind and Custom CSS
Tailwind v4.0.2 doesn't have built-in drag-state variants, so you're handling this through the isDragging boolean from useSortable and conditional class application. The pattern that works cleanly is a cn() utility (from clsx + tailwind-merge) toggling classes based on state.
For the placeholder — the ghost element left behind while dragging — set opacity to 0 or swap in a dashed border class when isDragging is true. Something like border-2 border-dashed border-gray-300 bg-gray-50 communicates the empty slot clearly. For the active drag state on the item in the overlay, a subtle scale and shadow go a long way: shadow-2xl ring-2 ring-blue-500/50.
/* If you need custom CSS for the drag overlay transition */
.dnd-overlay-enter {
opacity: 0;
transform: scale(0.95);
}
.dnd-overlay-enter-active {
opacity: 1;
transform: scale(1);
transition: opacity 150ms ease, transform 150ms ease;
}The transition value from useSortable handles the animation when items shuffle to make room. It defaults to something like transform 250ms ease. You can override it but the default is already smooth. One thing to watch: don't apply CSS transitions to the item while it's actively being dragged (isDragging === true), only when it's settling. Otherwise you get a rubber-band lag effect that feels wrong. This pairs well with the kind of visual polish you'd add if you're using Tailwind instead of CSS modules — keeping your drag state logic in JS and your visual tokens in Tailwind classes stays clean.
FAQ
onDragOver fires continuously as you move over droppable targets — use it to update state for multi-container moves so the UI reflects the new position in real time. onDragEnd fires once when you release. For single-list sorting, onDragEnd is enough. For Kanban-style multi-column boards, you need both: onDragOver to move items between columns and onDragEnd to finalize reordering within a column.
Use the activationConstraint with distance on PointerSensor, and also look at the handle pattern. Instead of spreading listeners onto the whole card, put them only on a dedicated drag handle element (like a grip icon). The rest of the card, including buttons and inputs, won't trigger a drag. You can also use the useSortable hook's setActivatorNodeRef to point at just the handle element.
Yes, but it takes extra work. The issue is that virtualized lists unmount items that scroll out of view, which breaks dnd kit's DOM measurements. You'll need to temporarily disable virtualization during a drag — keep the active item's container fully rendered. Some teams do this by measuring all item rects before a drag starts and storing them in a ref, then using a custom collision strategy based on those stored measurements.
In your onDragEnd handler, after calling arrayMove to update local state, fire an API call with the new ordered array of IDs. Debouncing helps if users sort rapidly. A common pattern is optimistic UI — update the state immediately and roll back if the API call fails. Pair this with a toast notification so users know the save succeeded or failed.
As of @dnd-kit/core v6.1.x, yes. The library uses refs and imperative DOM measurements rather than relying on synchronous renders, so it plays well with concurrent mode. The main thing to watch is that you're not calling setState inside an event handler that React might batch unexpectedly — the onDragEnd and onDragOver callbacks are fine since dnd kit dispatches them outside React's synthetic event system.
The transition value from useSortable already animates items sliding into place. If you want more control — like a spring animation — you can intercept the transform and pipe it through a motion library. With Framer Motion, wrap your sortable item in a motion.div and pass the transform values as a MotionValue. With React Spring, animate the CSS transform string directly. Just remember to disable the default transition when you do this to avoid double-animating.