Drag-and-Drop in React with @dnd-kit: Sortable Lists, Kanban, and Accessibility
Build accessible drag-and-drop interfaces with @dnd-kit: sortable lists, kanban boards, multi-container dragging, keyboard navigation, and custom drag overlays in React.
Drag-and-Drop in React with @dnd-kit: Sortable Lists, Kanban, and Accessibility
@dnd-kit replaced react-beautiful-dnd as the go-to drag-and-drop library for React because it handles the one thing rbd never did well: accessibility. Every drag interaction in @dnd-kit works with keyboard navigation out of the box, announces state changes to screen readers, and passes WCAG 2.1 AA without custom code.
This post builds three things: a simple sortable list, a multi-column kanban board with cross-column dragging, and a file upload zone with drag indicatorsβall with real TypeScript, state persistence, and the performance patterns that matter at scale.
Installation
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers
The package is modular: @dnd-kit/core is the foundation; @dnd-kit/sortable adds the sortable abstractions; @dnd-kit/modifiers provides constraint helpers (lock to axis, restrict to container).
Part 1: Sortable List
The most common use caseβa reorderable list of items.
// components/SortableList/SortableList.tsx
"use client";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import { GripVertical } from "lucide-react";
interface Item {
id: string;
title: string;
description?: string;
}
interface SortableItemProps {
item: Item;
isDragging?: boolean;
}
function SortableItem({ item, isDragging = false }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isSortableDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`
flex items-center gap-3 rounded-lg border bg-white p-4 shadow-sm
${isDragging ? "shadow-lg ring-2 ring-blue-500" : ""}
`}
>
{/* Drag handle β only the handle triggers drag, not the whole item */}
<button
className="cursor-grab touch-none text-gray-400 hover:text-gray-600 active:cursor-grabbing"
aria-label={`Drag to reorder ${item.title}`}
{...attributes}
{...listeners}
>
<GripVertical className="h-5 w-5" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.title}</p>
{item.description && (
<p className="text-sm text-gray-500 truncate">{item.description}</p>
)}
</div>
</div>
);
}
interface SortableListProps {
initialItems: Item[];
onReorder?: (items: Item[]) => void;
}
export function SortableList({ initialItems, onReorder }: SortableListProps) {
const [items, setItems] = useState(initialItems);
const [activeItem, setActiveItem] = useState<Item | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
// Require 8px of movement before drag starts (avoids accidental drags)
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function handleDragStart(event: DragStartEvent) {
const item = items.find((i) => i.id === event.active.id);
setActiveItem(item ?? null);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveItem(null);
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex((i) => i.id === active.id);
const newIndex = prev.findIndex((i) => i.id === over.id);
const reordered = arrayMove(prev, oldIndex, newIndex);
onReorder?.(reordered);
return reordered;
});
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{items.map((item) => (
<SortableItem key={item.id} item={item} />
))}
</div>
</SortableContext>
{/* DragOverlay renders the item being dragged at cursor position */}
<DragOverlay>
{activeItem ? (
<SortableItem item={activeItem} isDragging />
) : null}
</DragOverlay>
</DndContext>
);
}
Persisting order to the server:
// hooks/useSortableList.ts
import { useState, useCallback, useRef } from "react";
import { arrayMove } from "@dnd-kit/sortable";
import { useMutation } from "@tanstack/react-query";
import { DragEndEvent } from "@dnd-kit/core";
interface Orderable {
id: string;
order: number;
}
export function useSortableList<T extends Orderable>(
initialItems: T[],
persistUrl: string
) {
const [items, setItems] = useState(
[...initialItems].sort((a, b) => a.order - b.order)
);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const persistMutation = useMutation({
mutationFn: async (orderedIds: string[]) => {
const res = await fetch(persistUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedIds }),
});
if (!res.ok) throw new Error("Failed to persist order");
},
onError: (err, _vars, context: { previousItems: T[] } | undefined) => {
// Rollback optimistic update
if (context) setItems(context.previousItems);
console.error("Order persist failed:", err);
},
});
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setItems((prev) => {
const oldIndex = prev.findIndex((i) => i.id === active.id);
const newIndex = prev.findIndex((i) => i.id === over.id);
const reordered = arrayMove(prev, oldIndex, newIndex);
// Debounce server update (wait 500ms for rapid reorders)
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
persistMutation.mutate(reordered.map((i) => i.id));
}, 500);
return reordered;
});
},
[persistMutation]
);
return { items, handleDragEnd, isPersisting: persistMutation.isPending };
}
π Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different β senior engineers only, direct Slack access, and a 5.0β Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript β production-grade stack
- Fixed-price contracts β no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Part 2: Kanban Board with Cross-Column Dragging
// types/kanban.ts
export interface KanbanCard {
id: string;
title: string;
description?: string;
assignee?: string;
priority: "low" | "medium" | "high" | "urgent";
labels: string[];
}
export interface KanbanColumn {
id: string;
title: string;
color: string;
cardIds: string[];
limit?: number; // WIP limit
}
export interface KanbanState {
columns: Record<string, KanbanColumn>;
cards: Record<string, KanbanCard>;
columnOrder: string[];
}
// components/KanbanBoard/KanbanBoard.tsx
"use client";
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DragOverlay,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useState, useCallback } from "react";
import { KanbanState, KanbanCard, KanbanColumn } from "@/types/kanban";
import { KanbanColumnComponent } from "./KanbanColumn";
import { KanbanCardComponent } from "./KanbanCard";
interface Props {
initialState: KanbanState;
onStateChange?: (state: KanbanState) => void;
}
export function KanbanBoard({ initialState, onStateChange }: Props) {
const [state, setState] = useState(initialState);
const [activeCard, setActiveCard] = useState<KanbanCard | null>(null);
const [activeColumn, setActiveColumn] = useState<KanbanColumn | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Find which column a card belongs to
const findColumnForCard = useCallback(
(cardId: string): string | null => {
for (const [colId, col] of Object.entries(state.columns)) {
if (col.cardIds.includes(cardId)) return colId;
}
return null;
},
[state.columns]
);
function handleDragStart(event: DragStartEvent) {
const { active } = event;
if (active.data.current?.type === "Card") {
setActiveCard(state.cards[active.id as string] ?? null);
} else if (active.data.current?.type === "Column") {
setActiveColumn(state.columns[active.id as string] ?? null);
}
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Only handle card-over-column or card-over-card
if (active.data.current?.type !== "Card") return;
const activeColumnId = findColumnForCard(activeId);
let overColumnId: string | null = null;
if (over.data.current?.type === "Column") {
overColumnId = overId;
} else if (over.data.current?.type === "Card") {
overColumnId = findColumnForCard(overId);
}
if (!activeColumnId || !overColumnId || activeColumnId === overColumnId) return;
// Cross-column move (DragOver fires frequently β use functional update)
setState((prev) => {
const activeColCards = [...prev.columns[activeColumnId].cardIds];
const overColCards = [...prev.columns[overColumnId!].cardIds];
const activeIndex = activeColCards.indexOf(activeId);
activeColCards.splice(activeIndex, 1);
const overIndex = over.data.current?.type === "Card"
? overColCards.indexOf(overId)
: overColCards.length;
overColCards.splice(Math.max(0, overIndex), 0, activeId);
return {
...prev,
columns: {
...prev.columns,
[activeColumnId]: {
...prev.columns[activeColumnId],
cardIds: activeColCards,
},
[overColumnId!]: {
...prev.columns[overColumnId!],
cardIds: overColCards,
},
},
};
});
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveCard(null);
setActiveColumn(null);
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
if (activeId === overId) return;
// Column reorder
if (active.data.current?.type === "Column") {
setState((prev) => {
const oldIndex = prev.columnOrder.indexOf(activeId);
const newIndex = prev.columnOrder.indexOf(overId);
const newOrder = arrayMove(prev.columnOrder, oldIndex, newIndex);
const newState = { ...prev, columnOrder: newOrder };
onStateChange?.(newState);
return newState;
});
return;
}
// Same-column card reorder
const columnId = findColumnForCard(activeId);
if (!columnId) return;
setState((prev) => {
const col = prev.columns[columnId];
const oldIndex = col.cardIds.indexOf(activeId);
const newIndex = col.cardIds.indexOf(overId);
if (oldIndex === newIndex) return prev;
const newCardIds = arrayMove(col.cardIds, oldIndex, newIndex);
const newState = {
...prev,
columns: {
...prev.columns,
[columnId]: { ...col, cardIds: newCardIds },
},
};
onStateChange?.(newState);
return newState;
});
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 overflow-x-auto pb-4 min-h-screen items-start">
<SortableContext
items={state.columnOrder}
strategy={verticalListSortingStrategy}
>
{state.columnOrder.map((colId) => {
const column = state.columns[colId];
const cards = column.cardIds.map((id) => state.cards[id]).filter(Boolean);
return (
<KanbanColumnComponent
key={colId}
column={column}
cards={cards}
/>
);
})}
</SortableContext>
</div>
{/* Drag overlay: renders card/column at cursor during drag */}
<DragOverlay>
{activeCard ? (
<KanbanCardComponent card={activeCard} isDragging />
) : activeColumn ? (
<div className="w-72 opacity-80 rotate-3 bg-gray-100 rounded-xl p-4 shadow-2xl">
<h3 className="font-semibold">{activeColumn.title}</h3>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
// components/KanbanBoard/KanbanColumn.tsx
"use client";
import { useSortable, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { KanbanCard, KanbanColumn } from "@/types/kanban";
import { KanbanCardComponent } from "./KanbanCard";
interface Props {
column: KanbanColumn;
cards: KanbanCard[];
}
export function KanbanColumnComponent({ column, cards }: Props) {
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging,
} = useSortable({
id: column.id,
data: { type: "Column", column },
});
// Droppable for cards being dragged into this column
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: column.id,
data: { type: "Column" },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const isAtLimit = column.limit !== undefined && cards.length >= column.limit;
return (
<div
ref={setSortableRef}
style={style}
className="flex-shrink-0 w-72"
>
<div
className={`
rounded-xl border-2 bg-gray-50 transition-colors
${isOver && !isAtLimit ? "border-blue-400 bg-blue-50" : "border-transparent"}
${isDragging ? "shadow-2xl" : ""}
`}
>
{/* Column header β drag handle */}
<div
className="flex items-center justify-between p-4 cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: column.color }}
/>
<h3 className="font-semibold text-gray-900">{column.title}</h3>
<span className="text-sm text-gray-500 bg-gray-200 px-2 py-0.5 rounded-full">
{cards.length}
{column.limit ? `/${column.limit}` : ""}
</span>
</div>
{isAtLimit && (
<span className="text-xs text-amber-600 font-medium">WIP limit</span>
)}
</div>
{/* Cards */}
<div ref={setDroppableRef} className="px-2 pb-2 min-h-[100px]">
<SortableContext
items={cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{cards.map((card) => (
<KanbanCardComponent key={card.id} card={card} />
))}
</div>
</SortableContext>
{cards.length === 0 && (
<div className="flex items-center justify-center h-16 text-sm text-gray-400 border-2 border-dashed border-gray-200 rounded-lg">
Drop cards here
</div>
)}
</div>
</div>
</div>
);
}
// components/KanbanBoard/KanbanCard.tsx
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { KanbanCard } from "@/types/kanban";
const priorityColors = {
low: "border-l-gray-300",
medium: "border-l-blue-400",
high: "border-l-orange-400",
urgent: "border-l-red-500",
};
interface Props {
card: KanbanCard;
isDragging?: boolean;
}
export function KanbanCardComponent({ card, isDragging = false }: Props) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({
id: card.id,
data: { type: "Card", card },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`
rounded-lg border bg-white p-3 shadow-sm cursor-grab active:cursor-grabbing
border-l-4 ${priorityColors[card.priority]}
${isSortableDragging ? "opacity-0" : "opacity-100"}
${isDragging ? "shadow-xl rotate-2" : "hover:shadow-md"}
transition-shadow
`}
{...attributes}
{...listeners}
>
<p className="text-sm font-medium text-gray-900">{card.title}</p>
{card.description && (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{card.description}</p>
)}
{card.labels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{card.labels.map((label) => (
<span
key={label}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{label}
</span>
))}
</div>
)}
{card.assignee && (
<div className="mt-2 flex items-center gap-1">
<div className="h-5 w-5 rounded-full bg-gray-300 flex items-center justify-center text-xs font-medium">
{card.assignee[0].toUpperCase()}
</div>
<span className="text-xs text-gray-500">{card.assignee}</span>
</div>
)}
</div>
);
}
Accessibility
@dnd-kit announces drag state to screen readers automatically. To customize:
// accessibility/announcements.ts
import { Announcements } from "@dnd-kit/core";
export function buildAnnouncements(
getItemName: (id: string) => string,
getColumnName: (id: string) => string
): Announcements {
return {
onDragStart({ active }) {
return `Picked up card: ${getItemName(active.id as string)}. Use arrow keys to move.`;
},
onDragOver({ active, over }) {
if (!over) return;
return `Card ${getItemName(active.id as string)} is over ${
over.data.current?.type === "Column"
? `column ${getColumnName(over.id as string)}`
: `card ${getItemName(over.id as string)}`
}`;
},
onDragEnd({ active, over }) {
if (!over) return `Card ${getItemName(active.id as string)} dropped β no valid target.`;
return `Card ${getItemName(active.id as string)} moved to ${
over.data.current?.type === "Column"
? `column ${getColumnName(over.id as string)}`
: `position of ${getItemName(over.id as string)}`
}`;
},
onDragCancel({ active }) {
return `Drag cancelled. Card ${getItemName(active.id as string)} returned to original position.`;
},
};
}
Use in <DndContext accessibility={{ announcements }}>.
π Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4β8 weeks, full platforms in 3β5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Performance: Avoiding Re-renders
The classic mistake is putting all kanban state at the top level, causing every card to re-render on every drag. Fix with React.memo and stable IDs:
// Memoize individual cards β only re-render when the card data changes
const KanbanCardMemo = React.memo(KanbanCardComponent, (prev, next) => {
return prev.card.id === next.card.id &&
prev.card.title === next.card.title &&
prev.card.priority === next.card.priority &&
prev.isDragging === next.isDragging;
});
// Memoize column card lists
const columnCardIds = useMemo(
() => column.cardIds,
// Only recalculate when cardIds array reference changes
[column.cardIds]
);
For boards with 500+ cards, consider splitting state with zustand and subscribing columns only to their own card slice.
Cost and Timeline Estimates
| Component | Timeline | Cost (USD) |
|---|---|---|
| Simple sortable list | 0.5β1 day | $400β$800 |
| Single-column sortable with persist | 1β2 days | $800β$1,500 |
| Multi-column kanban board | 3β5 days | $2,400β$4,000 |
| Kanban with persistence + optimistic updates | 5β7 days | $4,000β$6,000 |
| Full board (keyboard, screen reader, undo) | 2 weeks | $8,000β$14,000 |
See Also
- React Context Patterns β State management for board configuration
- React Query Server State β Persisting board state optimistically
- TanStack Table v8 β Data table alternative for list views
- React Native Animations β Mobile drag-and-drop with Reanimated
Working With Viprasol
We build complex interactive UIs for SaaS productsβproject management tools, workflow builders, and data pipeline editors. Our frontend engineers have shipped @dnd-kit-based interfaces for applications handling real-time collaboration across hundreds of concurrent users.
What we deliver:
- Accessible drag-and-drop with full keyboard navigation
- Real-time collaborative boards with conflict resolution
- Optimistic updates and offline-first persistence
- Performance optimization for large boards (500+ items)
- Mobile touch support with gesture customization
Explore our web development services or contact us to discuss your interactive UI requirements.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms β we build it all with Next.js and React.
Free consultation β’ No commitment β’ Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse β₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β from architecture through deployment.