For most React apps, dnd-kit is the simplest solid choice: an MIT licensed toolkit with pointer, touch and keyboard sensors built in. Install @dnd-kit/react, wrap the area in DragDropProvider, give each card useSortable with an id, index and group, then update your state in onDragEnd. The native HTML drag events work too, with more hand-written code.
| dnd-kit (@dnd-kit/react) | Native HTML drag and drop | |
|---|---|---|
| Install | npm install @dnd-kit/react | Nothing, built into browsers |
| Keyboard | Keyboard sensor included | You build your own key controls |
| Touch | Touch and pointer sensors included | Varies by browser; test on phones |
| Sortable lists | useSortable from @dnd-kit/react/sortable | Your own index maths in drop handlers |
| Best for | Kanban boards, sortable lists, grids | Dropping files or simple one-off drops |
Updated
Below is a working board you can drag cards around on right now, with arrow buttons for anyone not using a mouse. Under it you will find the dnd-kit code for the same sortable list and multi-column board, plus the plain browser events version for comparison.
Drag cards between To do, Doing and Done, or use the move buttons on each card.
Install with npm install @dnd-kit/react. Each item knows its own index; after a drop, source.initialIndex and source.index tell you where it moved.
Reorders an array of task names. The copy-then-splice keeps the old state untouched.
import { useState } from "react";
import { DragDropProvider } from "@dnd-kit/react";
import { useSortable, isSortable } from "@dnd-kit/react/sortable";
function Task({ id, index }) {
const { ref } = useSortable({ id, index });
return <li ref={ref} className="rounded-lg border bg-white p-3">{id}</li>;
}
export default function SortableList() {
const [tasks, setTasks] = useState(["Write brief", "Pick photos", "Draft homepage", "Review copy"]);
function handleDragEnd(event) {
if (event.canceled) return;
const { source } = event.operation;
if (!isSortable(source) || source.initialIndex === source.index) return;
setTasks((list) => {
const next = [...list];
const [moved] = next.splice(source.initialIndex, 1);
next.splice(source.index, 0, moved);
return next;
});
}
return (
<DragDropProvider onDragEnd={handleDragEnd}>
<ul className="space-y-2">
{tasks.map((id, index) => <Task key={id} id={id} index={index} />)}
</ul>
</DragDropProvider>
);
}Add a group to useSortable so cards can travel between columns. source.initialGroup and source.group are the old and new column names.
Three columns held in one object. Moving inside a column reorders it; moving across removes the card from one array and inserts it into the other.
import { useState } from "react";
import { DragDropProvider } from "@dnd-kit/react";
import { useSortable, isSortable } from "@dnd-kit/react/sortable";
function Card({ id, index, column }) {
const { ref } = useSortable({ id, index, group: column, type: "card", accept: "card" });
return <li ref={ref} className="rounded-lg border bg-white p-3">{id}</li>;
}
export default function KanbanBoard() {
const [board, setBoard] = useState({
todo: ["Logo", "Pricing table"],
doing: ["Hero section"],
done: [],
});
function handleDragEnd(event) {
if (event.canceled) return;
const { source } = event.operation;
if (!isSortable(source)) return;
const { initialIndex, index, initialGroup, group } = source;
setBoard((b) => {
const from = [...b[initialGroup]];
const [card] = from.splice(initialIndex, 1);
if (initialGroup === group) {
from.splice(index, 0, card);
return { ...b, [group]: from };
}
const to = [...b[group]];
to.splice(index, 0, card);
return { ...b, [initialGroup]: from, [group]: to };
});
}
return (
<DragDropProvider onDragEnd={handleDragEnd}>
<div className="grid grid-cols-3 gap-4">
{Object.entries(board).map(([column, cards]) => (
<section key={column}>
<h2 className="mb-2 font-semibold capitalize">{column}</h2>
<ul className="min-h-24 space-y-2 rounded-xl bg-slate-100 p-2">
{cards.map((id, index) => <Card key={id} id={id} index={index} column={column} />)}
</ul>
</section>
))}
</div>
</DragDropProvider>
);
}No package needed. Note the preventDefault in onDragOver: without it, the drop never fires.
Moves a card to the end of the column it is dropped on. Reordering inside a column would need extra index logic.
import { useState } from "react";
export default function NativeColumns() {
const [board, setBoard] = useState({ todo: ["Logo", "Menu"], done: [] });
function drop(e, column) {
e.preventDefault();
const { card, from } = JSON.parse(e.dataTransfer.getData("text/plain"));
if (from === column) return;
setBoard((b) => ({
...b,
[from]: b[from].filter((c) => c !== card),
[column]: [...b[column], card],
}));
}
return (
<div style={{ display: "flex", gap: 16 }}>
{Object.entries(board).map(([column, cards]) => (
<ul key={column} onDragOver={(e) => e.preventDefault()} onDrop={(e) => drop(e, column)}
style={{ minHeight: 120, minWidth: 160, padding: 8, background: "#f1f5f9" }}>
{cards.map((card) => (
<li key={card} draggable
onDragStart={(e) => e.dataTransfer.setData("text/plain", JSON.stringify({ card, from: column }))}>
{card}
</li>
))}
</ul>
))}
</div>
);
}| Event | Fires on | When |
|---|---|---|
| dragstart | The dragged element | The drag begins; set dataTransfer here |
| drag | The dragged element | Repeatedly while dragging |
| dragenter | A possible target | The dragged item enters it |
| dragover | A possible target | Repeatedly while over it; call preventDefault to allow a drop |
| dragleave | A possible target | The dragged item leaves it |
| drop | The target | The item is released on a valid target; read dataTransfer here |
| dragend | The dragged element | The drag finishes, dropped or cancelled |
A board is an object of arrays: one array of card ids per column. Dragging only decides which card moved, where it came from and where it landed. Every library hands you those four facts, and your code turns them into a new object.
Write that move function as a pure helper and test it without a browser. dnd-kit even ships one, move from @dnd-kit/helpers, which takes your items and the drag event and returns the updated copy.
The browser fires seven events (dragstart, drag, dragenter, dragleave, dragover, drop and dragend) and you must cancel dragover on every valid target. Data travels through event.dataTransfer as strings, and you can only read it inside the drop handler.
There is no keyboard path and no built-in way to animate the other cards out of the way. That is fine for dropping a file onto an upload area, but a sortable board quickly grows into a small library of its own.
Atlassian archived the react-beautiful-dnd repository and recommends its successor, Pragmatic drag and drop. For new React work, dnd-kit or Pragmatic drag and drop are the actively maintained options to compare.
@dnd-kit/core with @dnd-kit/sortable is the long-running React package pair. @dnd-kit/react is the newer adapter built on a framework-agnostic core that also powers the Vue, Svelte and Solid adapters. New code can start with @dnd-kit/react as the current docs do.
A drop target must call event.preventDefault() in its dragover handler. Without that call the browser treats the element as an invalid target and the drop event is never sent.
Make each card focusable, offer a keyboard way to pick up, move and drop it, and announce the result in a live region. dnd-kit ships a keyboard sensor and accessibility support out of the box; with the native API you add all of it yourself.
Related: React table examples, JavaScript event listeners, Drag and drop website editing.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.