Implementing Drag-and-Drop Interface (Kanban Board) on Website
Drag-and-drop at first glance seems simple — until you start dealing with touch devices, autoscroll when dragging to edge, nested containers, and accessibility. HTML5 Drag-and-Drop API covers basic scenarios, but for Kanban board with multiple columns and nested lists, you need a library.
Library Choice
@dnd-kit/core — modern standard for React. Works with touch and keyboard, accessibility support out of box, doesn't depend on DOM order. Size ~10 KB gzipped.
react-beautiful-dnd — popular, but development frozen. Don't use in new projects.
Sortable.js — vanilla option, works with any framework, but requires more manual work for React state integration.
Installing dnd-kit
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Data Structure
type CardId = string
type ColumnId = string
interface Card {
id: CardId
title: string
description?: string
assignee?: string
priority: 'low' | 'medium' | 'high'
}
interface Column {
id: ColumnId
title: string
cardIds: CardId[]
}
interface BoardState {
cards: Record<CardId, Card>
columns: Record<ColumnId, Column>
columnOrder: ColumnId[]
}
Normalized structure (cards separate from columns) simplifies moving: just update cardIds in the needed columns.
DndContext and Sensors
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
KeyboardSensor,
TouchSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core'
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
function KanbanBoard() {
const [board, setBoard] = useState<BoardState>(initialBoard)
const [activeCardId, setActiveCardId] = useState<CardId | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // pixels before DnD starts — so clicks work
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250, // delay on touch — avoid scroll conflict
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
function handleDragStart(event: DragStartEvent) {
setActiveCardId(event.active.id as CardId)
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event
if (!over) return
const activeColId = findColumnByCardId(board, active.id as CardId)
const overColId = isColumnId(over.id)
? (over.id as ColumnId)
: findColumnByCardId(board, over.id as CardId)
if (!activeColId || !overColId || activeColId === overColId) return
// Move card between columns during drag (live preview)
setBoard((prev) => moveCardBetweenColumns(prev, active.id as CardId, activeColId, overColId, over.id as CardId))
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveCardId(null)
if (!over) return
const activeColId = findColumnByCardId(board, active.id as CardId)
const overColId = isColumnId(over.id)
? (over.id as ColumnId)
: findColumnByCardId(board, over.id as CardId)
if (!activeColId || !overColId) return
if (activeColId === overColId) {
// Sort within one column
setBoard((prev) => reorderCardInColumn(prev, activeColId, active.id as CardId, over.id as CardId))
}
// Between columns already handled in handleDragOver
}
const activeCard = activeCardId ? board.cards[activeCardId] : null
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 overflow-x-auto p-4">
{board.columnOrder.map((colId) => (
<KanbanColumn
key={colId}
column={board.columns[colId]}
cards={board.columns[colId].cardIds.map((id) => board.cards[id])}
/>
))}
</div>
<DragOverlay>
{activeCard ? <KanbanCard card={activeCard} isDragging /> : null}
</DragOverlay>
</DndContext>
)
}
DragOverlay renders a "ghost" card on top of everything — without it, the card disappears from column during dragging, which looks bad.
SortableContext and Column
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { useDroppable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
function KanbanColumn({ column, cards }: { column: Column; cards: Card[] }) {
// Column as drop-target for empty zones
const { setNodeRef, isOver } = useDroppable({ id: column.id })
return (
<div
className={`w-72 rounded-lg bg-gray-100 p-3 flex-shrink-0 ${
isOver ? 'ring-2 ring-blue-400' : ''
}`}
>
<h3 className="font-semibold mb-3">{column.title}</h3>
<SortableContext
items={cards.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
<div ref={setNodeRef} className="space-y-2 min-h-[48px]">
{cards.map((card) => (
<SortableCard key={card.id} card={card} />
))}
</div>
</SortableContext>
</div>
)
}
function SortableCard({ card }: { card: Card }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1, // original position becomes semi-transparent
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="bg-white rounded-md p-3 shadow-sm cursor-grab active:cursor-grabbing"
>
<KanbanCard card={card} />
</div>
)
}
Move Utilities
function moveCardBetweenColumns(
board: BoardState,
cardId: CardId,
fromColId: ColumnId,
toColId: ColumnId,
overCardId: CardId | ColumnId
): BoardState {
const fromCol = board.columns[fromColId]
const toCol = board.columns[toColId]
const newFromIds = fromCol.cardIds.filter((id) => id !== cardId)
const insertIndex = isColumnId(overCardId)
? toCol.cardIds.length
: toCol.cardIds.indexOf(overCardId as CardId)
const newToIds = [...toCol.cardIds]
newToIds.splice(insertIndex, 0, cardId)
return {
...board,
columns: {
...board.columns,
[fromColId]: { ...fromCol, cardIds: newFromIds },
[toColId]: { ...toCol, cardIds: newToIds },
},
}
}
State Persistence
To save order after reload — sync with backend after each dragEnd:
const mutation = useMutation({
mutationFn: (update: BoardUpdatePayload) =>
api.patch('/boards/main', update),
onError: (_, __, context) => {
// Rollback on error
setBoard(context!.previousBoard)
toast.error('Failed to save changes')
},
})
function handleDragEnd(event: DragEndEvent) {
// ... move logic
const previousBoard = board
const newBoard = applyDragEnd(board, event)
setBoard(newBoard) // optimistic update
mutation.mutate(
{ cardId: event.active.id, columnId: targetColId, position: newPosition },
{ context: { previousBoard } }
)
}
Sorting Columns
Columns themselves can be dragged — columns are wrapped in SortableContext at board level:
<SortableContext
items={board.columnOrder}
strategy={horizontalListSortingStrategy}
>
{board.columnOrder.map((colId) => (
<SortableColumn key={colId} column={board.columns[colId]} ... />
))}
</SortableContext>
Determining what's dragged (card or column) — through data type in active.data.current.
What We Do
Design data structure for specific task (could be task board, sales funnel, content editor). Set up DnD with touch and keyboard support, implement live preview via DragOverlay, connect API sync with optimistic updates and rollback on error.
Timeframe: basic Kanban board — 3–4 days. With column sorting, nested tasks, and offline sync — 6–8 days.







