Drag-and-Drop Kanban in React: dnd-kit Full Implementation
Build a fully working drag-and-drop Kanban board in React with dnd-kit — columns, cards, sorting, and keyboard support all covered from scratch.
Why dnd-kit Instead of react-beautiful-dnd
Honestly, the answer is simple: react-beautiful-dnd is effectively dead. Atlassian archived it in 2023, and it relies on ReactDOM.findDOMNode which React 18's Strict Mode yells about constantly. If you're starting a new Kanban board today, dnd-kit is the obvious pick.
dnd-kit v6 (released late 2023) is a modular drag-and-drop toolkit — not a monolith. You pull in @dnd-kit/core for the primitives, @dnd-kit/sortable for list/column sorting, and nothing else unless you need it. The bundle footprint is tiny compared to alternatives, and it's built from scratch around pointer events rather than the older HTML5 drag API, which means you get actual touch support without hacks.
The learning curve is real, though. dnd-kit gives you a lot of control and not a lot of magic. You wire up sensors, collision detection, and overlay rendering yourself. That's a feature if you need custom behavior — and a drag (sorry) if you just want a quick Trello clone. This article walks through every piece so you know exactly what to wire where.
Worth noting: dnd-kit has first-class keyboard accessibility built in. Every draggable element is automatically keyboard-navigable without extra work on your end. That alone is worth the setup overhead if your users include keyboard-primary folks.
Project Setup and Dependencies
Start with a fresh Vite + React + TypeScript project, or drop this into an existing Next.js 14+ app — it works either way. Install the two packages you actually need:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesThe @dnd-kit/utilities package gives you CSS.Transform.toString() which you'll use repeatedly for applying drag transforms. You don't technically need it but it saves you 20 lines of manual CSS string building.
For the Kanban board structure, you need to think in two levels: columns (the lanes) and cards (the items inside each lane). dnd-kit handles both, but you need separate sortable contexts for each level. This is the part that trips people up the most — one SortableContext for column ordering, one per column for card ordering inside that column.
// types.ts
export interface Card {
id: string;
content: string;
columnId: string;
}
export interface Column {
id: string;
title: string;
}
export interface KanbanState {
columns: Column[];
cards: Card[];
}Building the Data Model and State
Before touching a single drag hook, nail down your state shape. The cleanest approach for a Kanban board is flat arrays — columns array and cards array where each card has a columnId. No nested objects. This makes re-ordering across columns a simple filter-and-splice operation rather than deep cloning nightmares.
// useBoardState.ts
import { useState, useCallback } from 'react';
import type { Card, Column } from './types';
import { arrayMove } from '@dnd-kit/sortable';
const INITIAL_COLUMNS: Column[] = [
{ id: 'todo', title: 'To Do' },
{ id: 'in-progress', title: 'In Progress' },
{ id: 'done', title: 'Done' },
];
const INITIAL_CARDS: Card[] = [
{ id: 'card-1', content: 'Design mockups', columnId: 'todo' },
{ id: 'card-2', content: 'Set up API routes', columnId: 'todo' },
{ id: 'card-3', content: 'Build auth flow', columnId: 'in-progress' },
{ id: 'card-4', content: 'Write unit tests', columnId: 'done' },
];
export function useBoardState() {
const [columns, setColumns] = useState<Column[]>(INITIAL_COLUMNS);
const [cards, setCards] = useState<Card[]>(INITIAL_CARDS);
const moveCard = useCallback(
(cardId: string, targetColumnId: string, overCardId?: string) => {
setCards((prev) => {
const cardIndex = prev.findIndex((c) => c.id === cardId);
const updated = prev.map((c) =>
c.id === cardId ? { ...c, columnId: targetColumnId } : c
);
if (overCardId) {
const overIndex = updated.findIndex((c) => c.id === overCardId);
return arrayMove(updated, cardIndex, overIndex);
}
return updated;
});
},
[]
);
const moveColumn = useCallback((fromIndex: number, toIndex: number) => {
setColumns((prev) => arrayMove(prev, fromIndex, toIndex));
}, []);
return { columns, cards, moveCard, moveColumn };
}The arrayMove helper from @dnd-kit/sortable is doing a lot of heavy lifting here — it returns a new array with the element moved from one index to another without mutation. Use it everywhere instead of rolling your own splice logic.
In practice, you'll also want useMemo to derive per-column card lists rather than filtering inside every column component render. A quick const columnCards = useMemo(() => cards.filter(c => c.columnId === column.id), [cards, column.id]) inside each column component keeps renders tight.
The DndContext Setup and Drag Handlers
The DndContext from @dnd-kit/core is your root wrapper — everything draggable lives inside it. You'll configure sensors (which input methods trigger drag), collision detection (how it decides what's being hovered), and three event handlers: onDragStart, onDragOver, and onDragEnd.
// KanbanBoard.tsx
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCorners,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useState } from 'react';
import { useBoardState } from './useBoardState';
import { KanbanColumn } from './KanbanColumn';
import { CardItem } from './CardItem';
import type { Card } from './types';
export function KanbanBoard() {
const { columns, cards, moveCard, moveColumn } = useBoardState();
const [activeCard, setActiveCard] = useState<Card | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
// Require 8px of movement before drag starts — prevents accidental drags
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function handleDragStart({ active }: DragStartEvent) {
const card = cards.find((c) => c.id === active.id);
setActiveCard(card ?? null);
}
function handleDragOver({ active, over }: DragOverEvent) {
if (!over) return;
const activeCard = cards.find((c) => c.id === active.id);
if (!activeCard) return;
// Check if hovering over a column directly
const isOverColumn = columns.some((col) => col.id === over.id);
if (isOverColumn && activeCard.columnId !== over.id) {
moveCard(active.id as string, over.id as string);
}
}
function handleDragEnd({ active, over }: DragEndEvent) {
setActiveCard(null);
if (!over || active.id === over.id) return;
const activeCard = cards.find((c) => c.id === active.id);
const overCard = cards.find((c) => c.id === over.id);
if (activeCard && overCard) {
moveCard(
active.id as string,
overCard.columnId,
over.id as string
);
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 p-6 h-screen overflow-x-auto">
<SortableContext
items={columns.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
{columns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
cards={cards.filter((c) => c.columnId === column.id)}
/>
))}
</SortableContext>
</div>
<DragOverlay>
{activeCard ? <CardItem card={activeCard} isOverlay /> : null}
</DragOverlay>
</DndContext>
);
}The DragOverlay is the ghost element that follows your cursor during a drag. Without it, dnd-kit still works but the dragged element disappears in place and the UX feels broken. Always render a DragOverlay — match its visual style to the real card but you can add opacity-80 shadow-2xl rotate-2 to sell the lift effect.
Quick aside: closestCorners vs closestCenter collision detection. For Kanban boards where columns are wide and cards are tall, closestCorners tends to feel more natural because it measures distance to the nearest corner of a droppable rather than its center. Try both in your UI — the difference is subtle but real at the edges of columns.
That activationConstraint: { distance: 8 } on the PointerSensor deserves a mention. Without it, clicking a card with a mouse triggers a drag and any click handlers on the card fire incorrectly. 8px is enough movement to distinguish intentional drags from clicks on virtually every device.
Column and Card Components with useSortable
The useSortable hook from @dnd-kit/sortable is what makes an individual element draggable and droppable. You call it inside both the column component and the card component. It gives you attributes, listeners, setNodeRef, and the transform data you need to animate the drag.
// CardItem.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Card } from './types';
interface CardItemProps {
card: Card;
isOverlay?: boolean;
}
export function CardItem({ card, isOverlay = false }: CardItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={[
'p-3 bg-white rounded-lg border border-gray-200 cursor-grab',
'shadow-sm text-sm text-gray-800 select-none',
isDragging ? 'opacity-30' : 'opacity-100',
isOverlay ? 'shadow-xl rotate-1 cursor-grabbing' : '',
].join(' ')}
>
{card.content}
</div>
);
}// KanbanColumn.tsx
import { useSortable } from '@dnd-kit/sortable';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useDroppable } from '@dnd-kit/core';
import type { Card, Column } from './types';
import { CardItem } from './CardItem';
interface KanbanColumnProps {
column: Column;
cards: Card[];
}
export function KanbanColumn({ column, cards }: KanbanColumnProps) {
const { setNodeRef } = useDroppable({ id: column.id });
return (
<div className="flex flex-col w-72 flex-shrink-0">
<div className="mb-3 font-semibold text-gray-700 px-1">
{column.title}
<span className="ml-2 text-xs text-gray-400">{cards.length}</span>
</div>
<div
ref={setNodeRef}
className="flex flex-col gap-2 flex-1 bg-gray-50 rounded-xl p-2 min-h-[200px]"
>
<SortableContext
items={cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{cards.map((card) => (
<CardItem key={card.id} card={card} />
))}
</SortableContext>
</div>
</div>
);
}Notice the column uses useDroppable rather than useSortable — columns are drop targets for cards moving between them, but in this implementation we're not making columns themselves draggable. If you want draggable column reordering too, swap useDroppable for useSortable on the column and add the transform/transition styles. The handleDragEnd would then need to distinguish between column drags and card drags by checking whether active.id matches a column id.
One more thing — the empty column drop target. When a column has zero cards, you need min-h-[200px] (or similar) on the droppable container. Without a minimum height, the drop zone collapses to zero pixels and dnd-kit's collision detection can't hit it. 200px works well in practice. This is a footgun that catches almost everyone the first time.
Styling the Board with a Visual Identity
A functional Kanban board is one thing. One that looks good enough to ship is another. The implementation above uses basic Tailwind utilities, but you can push it significantly further without touching the drag logic at all — it's all cosmetic from here.
If you want a dark glassmorphism Kanban — the kind you'd see on a premium SaaS dashboard — the glassmorphism components on Empire UI are a direct drop-in. Replace the bg-white card background with bg-white/10 backdrop-blur-md border border-white/10 and put a gradient background behind the whole board. The drag overlay's rotate-1 and shadow-xl become much more visible and satisfying on a frosted surface.
For a neobrutalism flavor, check out the neobrutalism style hub — thick 2px solid borders, offset box-shadows (4px 4px 0 black), and flat saturated column header colors. You can actually use the box shadow generator to dial in the exact offset values before hardcoding them. That approach takes maybe 20 minutes to style and looks genuinely distinct.
Cards with richer content — assignees, labels, due dates — benefit from a structured internal layout. Don't go overboard on the first pass. A card that's 64px tall with a label color strip on the left edge, a one-line content text, and an avatar at the bottom right covers 80% of real Kanban use cases cleanly.
Performance-wise: if your board has more than ~300 cards visible at once, you'll want to think about virtualization inside columns. @tanstack/virtual handles this well and composes cleanly with dnd-kit's sortable context since you control the rendered item list.
Common Pitfalls and How to Fix Them
Cards flickering or snapping back on drop is the most common complaint. Nine times out of ten it's because onDragOver and onDragEnd are both mutating state, causing a double-move. The pattern that works: use onDragOver *only* for cross-column card moves (changing columnId), and use onDragEnd *only* for within-column reordering. Don't move a card in both handlers.
Drag not activating on mobile? Check that you have a PointerSensor (not a MouseSensor) and that your card element doesn't have touch-action: auto in its CSS. dnd-kit needs touch-action: none on draggable elements to intercept touch events. Adding [touch-action: none] to your card's Tailwind config or inline style fixes it immediately.
Keyboard navigation not working? Make sure KeyboardSensor is included in your useSensors call and that sortableKeyboardCoordinates is passed as the coordinateGetter. Also check that your card elements receive focus — they need tabIndex={0} which useSortable's attributes spread provides automatically as long as you spread {...attributes} on the DOM node.
IDs must be unique across the entire board — not just within a column. If a card and a column share an ID string, dnd-kit's collision detection breaks in ways that are genuinely confusing to debug. Use prefixed IDs like col-todo and card-1 to guarantee separation. This is obvious in retrospect but the error messages don't point directly at it.
Look, the dnd-kit docs are good but the multi-container example they ship is more complex than most teams need. Start with this article's flat data model, get it working, then layer complexity as your product demands it. The React ecosystem has a habit of reaching for complexity before it's earned — especially with drag-and-drop libraries that offer 40 configuration options on day one.
FAQ
Yes, but DndContext and all drag hooks rely on browser APIs, so your board component needs the 'use client' directive. Wrap just the board, not your entire layout.
Call your API in onDragEnd after updating local state — optimistic updates first, then sync. Store column order as an array of IDs and card order as a position integer or an ordered array per column.
Yes — use PointerSensor (not MouseSensor) and add touch-action: none to your draggable elements. PointerSensor handles mouse, touch, and stylus events with the same code path.
useDraggable is a lower-level primitive — it makes something draggable but knows nothing about ordering. useSortable builds on top of it and adds sortable context awareness, meaning it calculates where to shift other items as you drag.