React Drag and Drop with dnd-kit Guide (2026)

dnd-kit is the modern drag-and-drop library for React — it replaced react-beautiful-dnd as the community standard after react-beautiful-dnd was deprecated. dnd-kit is headless, composable, accessible by default and works with pointer events, touch and keyboard. This guide builds from simple draggables up to a full Kanban board.

Setup and Core Concepts

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

dnd-kit has three layers:

  • @dnd-kit/coreDndContext, useDraggable, useDroppable, sensors
  • @dnd-kit/sortableSortableContext, useSortable, arrayMove
  • @dnd-kit/utilitiesCSS.Transform.toString and other helpers

Basic Draggable and Droppable

import {
  DndContext,
  useDraggable,
  useDroppable,
  DragEndEvent,
} from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'

function Draggable({ id, children }) {
  const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id })

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        opacity: isDragging ? 0.5 : 1,
        cursor: 'grab',
      }}
      {...listeners}   // Pointer/touch/keyboard event handlers
      {...attributes}  // ARIA attributes for accessibility
    >
      {children}
    </div>
  )
}

function Droppable({ id, children }) {
  const { setNodeRef, isOver } = useDroppable({ id })

  return (
    <div
      ref={setNodeRef}
      style={{
        background: isOver ? 'rgba(99,102,241,0.15)' : 'transparent',
        border: '2px dashed',
        borderColor: isOver ? '#6366f1' : '#e2e8f0',
        borderRadius: 8,
        padding: 16,
        minHeight: 100,
        transition: 'background 0.2s',
      }}
    >
      {children}
    </div>
  )
}

function DragDropDemo() {
  const [dropped, setDropped] = useState(false)

  function handleDragEnd(event: DragEndEvent) {
    if (event.over?.id === 'droppable') setDropped(true)
  }

  return (
    <DndContext onDragEnd={handleDragEnd}>
      {!dropped && <Draggable id="draggable">Drag me</Draggable>}
      <Droppable id="droppable">
        {dropped ? 'Dropped!' : 'Drop here'}
      </Droppable>
    </DndContext>
  )
}

Sortable List

import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from '@dnd-kit/core'
import {
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable,
  arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

// Individual sortable item
function SortableItem({ id, label }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id })

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        opacity: isDragging ? 0.4 : 1,
        zIndex: isDragging ? 1 : 0,
      }}
      className="flex items-center gap-3 p-3 bg-white border rounded-lg mb-2 shadow-sm"
      {...attributes}
    >
      {/* Drag handle — only this area initiates drag */}
      <span {...listeners} style={{ cursor: 'grab', touchAction: 'none' }}>⠿</span>
      {label}
    </div>
  )
}

// Sortable list container
function SortableList() {
  const [items, setItems] = useState([
    { id: '1', label: 'Item One' },
    { id: '2', label: 'Item Two' },
    { id: '3', label: 'Item Three' },
    { id: '4', label: 'Item Four' },
  ])

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  )

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    if (over && active.id !== over.id) {
      setItems(items => {
        const oldIndex = items.findIndex(i => i.id === active.id)
        const newIndex = items.findIndex(i => i.id === over.id)
        return arrayMove(items, oldIndex, newIndex)
      })
    }
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext
        items={items.map(i => i.id)}
        strategy={verticalListSortingStrategy}
      >
        {items.map(item => (
          <SortableItem key={item.id} id={item.id} label={item.label} />
        ))}
      </SortableContext>
    </DndContext>
  )
}

Kanban Board

import { useState } from 'react'
import { DndContext, DragOverEvent, DragEndEvent } from '@dnd-kit/core'
import { SortableContext } from '@dnd-kit/sortable'

type Card = { id: string; title: string; columnId: string }
type Column = { id: string; title: string }

const COLUMNS: Column[] = [
  { id: 'todo', title: 'To Do' },
  { id: 'in-progress', title: 'In Progress' },
  { id: 'done', title: 'Done' },
]

function KanbanBoard() {
  const [cards, setCards] = useState<Card[]>([
    { id: 'c1', title: 'Design homepage', columnId: 'todo' },
    { id: 'c2', title: 'Build API', columnId: 'in-progress' },
    { id: 'c3', title: 'Write tests', columnId: 'todo' },
    { id: 'c4', title: 'Deploy to prod', columnId: 'done' },
  ])

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    if (!over) return

    const cardId = active.id as string
    const overId = over.id as string

    // Check if dropped over a column or another card
    const overColumn = COLUMNS.find(c => c.id === overId)
    const overCard = cards.find(c => c.id === overId)
    const targetColumnId = overColumn?.id ?? overCard?.columnId

    if (targetColumnId) {
      setCards(cards => cards.map(c => c.id === cardId ? { ...c, columnId: targetColumnId } : c))
    }
  }

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <div className="flex gap-4">
        {COLUMNS.map(column => {
          const columnCards = cards.filter(c => c.columnId === column.id)
          return (
            <KanbanColumn key={column.id} column={column} cards={columnCards} />
          )
        })}
      </div>
    </DndContext>
  )
}

function KanbanColumn({ column, cards }) {
  const { setNodeRef, isOver } = useDroppable({ id: column.id })

  return (
    <div
      ref={setNodeRef}
      className="flex-1 min-h-64 rounded-xl p-4"
      style={{ background: isOver ? 'rgba(99,102,241,0.08)' : 'rgba(17,24,39,0.6)' }}
    >
      <h3 className="font-semibold mb-3">{column.title} ({cards.length})</h3>
      <SortableContext items={cards.map(c => c.id)}>
        {cards.map(card => <KanbanCard key={card.id} card={card} />)}
      </SortableContext>
    </div>
  )
}

Collision Detection

import {
  closestCenter,    // Best for sortable lists
  closestCorners,   // Best for Kanban (detects which corner is closest)
  rectIntersection, // Overlap-based — good for free-form drag
  pointerWithin,    // Pointer must be inside the droppable
} from '@dnd-kit/core'

// For Kanban: closestCorners prevents incorrect column detection
<DndContext collisionDetection={closestCorners} />

// Custom collision — prefer items over containers
import { MeasuringStrategy } from '@dnd-kit/core'

<DndContext
  collisionDetection={closestCorners}
  measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
/>

Accessibility

// dnd-kit announces drag state to screen readers automatically.
// Customize announcements:
import { Announcements } from '@dnd-kit/core'

const announcements: Announcements = {
  onDragStart: ({ active }) => `Picked up card ${active.id}.`,
  onDragOver: ({ active, over }) =>
    over ? `Card ${active.id} is over column ${over.id}.` : `Card ${active.id} is no longer over a column.`,
  onDragEnd: ({ active, over }) =>
    over ? `Card ${active.id} dropped into column ${over.id}.` : `Card ${active.id} was dropped.`,
  onDragCancel: ({ active }) => `Dragging ${active.id} was cancelled.`,
}

<DndContext accessibility={{ announcements }}>