Kanban board with native drag and drop
A library-free React kanban board using HTML5 drag events and a pure moveCard function to reorder across columns.
React / Next.jsCardsreactkanbandrag-dropboard
Live preview
Code
'use client';
import { useState, type DragEvent } from 'react';
type Card = { id: string; title: string };
type Board = Record<string, Card[]>;
const seed: Board = {
Todo: [{ id: 'a', title: 'Draft proposal' }, { id: 'b', title: 'Review pricing' }],
Doing: [{ id: 'c', title: 'Build landing page' }],
Done: [{ id: 'd', title: 'Kick-off call' }],
};
// Pure: returns a new board with the card removed and re-inserted at `index`.
export function moveCard(board: Board, id: string, to: string, index: number): Board {
let card: Card | undefined;
const without: Board = {};
for (const [col, cards] of Object.entries(board)) {
without[col] = cards.filter((c) => (c.id === id ? ((card = c), false) : true));
}
if (!card) return board;
const target = [...without[to]!];
target.splice(Math.min(index, target.length), 0, card);
return { ...without, [to]: target };
}
export function Kanban() {
const [board, setBoard] = useState(seed);
const [over, setOver] = useState<string | null>(null);
const drop = (e: DragEvent, col: string, index: number) => {
e.preventDefault();
e.stopPropagation(); // a card drop must not also trigger the column drop
setOver(null);
const id = e.dataTransfer.getData('text/plain');
if (id) setBoard((b) => moveCard(b, id, col, index));
};
return (
<div className="grid gap-4 sm:grid-cols-3">
{Object.entries(board).map(([col, cards]) => (
<section
key={col}
aria-label={col}
onDragOver={(e) => { e.preventDefault(); setOver(col); }}
onDragLeave={() => setOver((c) => (c === col ? null : c))}
onDrop={(e) => drop(e, col, cards.length)}
className={`min-h-40 rounded-lg p-3 ${over === col ? 'bg-blue-50' : 'bg-slate-100'}`}
>
<h3 className="mb-2 text-sm font-semibold">{col} ({cards.length})</h3>
<ul className="space-y-2">
{cards.map((card, i) => (
<li
key={card.id}
draggable
onDragStart={(e) => e.dataTransfer.setData('text/plain', card.id)}
onDrop={(e) => drop(e, col, i)}
className="cursor-grab rounded bg-white p-2 text-sm shadow-sm active:cursor-grabbing"
>
{card.title}
</li>
))}
</ul>
</section>
))}
</div>
);
}
How it works
What it solves
Task boards are usually built with heavy drag libraries, yet the browser already ships drag-and-drop. For a simple board with a handful of columns the native events are enough and add nothing to your bundle.
How it works
Data is a record from column id to an array of cards. The pure function moveCard removes a card from wherever it currently lives and inserts it into the target column at a given index, returning new objects so React re-renders. Keeping it pure makes it easy to test and reuse with server actions.
On dragstart the card id is stored in dataTransfer. Each card is also a drop target: dropping on a card inserts before it, while dropping on the empty area of a column appends to the end. dragover calls preventDefault to allow dropping, and a hover state highlights the active column. The column check ignores a drop on the card itself, so dragging onto its own position does nothing.
The transfer carries only an id, so the source column is looked up from state instead of being trusted from the event.
Adapting it
- Persist the record with a localStorage hook or a server action after each move.
- Add keyboard support with buttons that call
moveCardwith index plus or minus one. - Show a placeholder line by tracking the hovered card index.
Pitfalls
- Native drag does not work on touch screens; offer move buttons for mobile.
- Always keep card ids unique across all columns.
- Set
draggableon the card, not a child, or Firefox starts a text selection.
How to use it
- Save as
components/Kanban.tsx(client component). - No dependencies.
- Render
<Kanban />; replace theseedobject with your own columns and cards. - For persistence, call your save function inside
setBoardaftermoveCard.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.