React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

drag-and-dropdnd-kitsortableaccessibilityinteraction

dnd-kit - Lightweight, accessible drag and drop for React with sortable lists and multiple containers

Recipe

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
"use client";
import { useState } from "react";
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
 
function SortableItem({ id }: { id: string }) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });
 
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
 
  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="p-3 bg-white border rounded shadow-sm cursor-grab"
    >
      {id}
    </div>
  );
}
 
export default function SortableList() {
  const [items, setItems] = useState(["Item 1", "Item 2", "Item 3"]);
  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.indexOf(active.id as string);
        const newIndex = items.indexOf(over.id as string);
        return arrayMove(items, oldIndex, newIndex);
      });
    }
  }
 
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
      <SortableContext items={items} strategy={verticalListSortingStrategy}>
        <div className="space-y-2">
          {items.map((id) => (
            <SortableItem key={id} id={id} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}

When to reach for this: You need drag-and-drop reordering, sortable lists, kanban boards, or draggable UI elements with keyboard accessibility.

Working Example

// app/components/KanbanBoard.tsx
"use client";
import { useState } from "react";
import {
  DndContext,
  DragOverlay,
  closestCorners,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type DragStartEvent,
  type DragEndEvent,
  type DragOverEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core";
 
interface Task {
  id: string;
  title: string;
}
 
type Columns = Record<string, Task[]>;
 
function TaskCard({ task }: { task: Task }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id: task.id });
 
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };
 
  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="p-3 bg-white border rounded shadow-sm cursor-grab"
    >
      {task.title}
    </div>
  );
}
 
function Column({ id, tasks }: { id: string; tasks: Task[] }) {
  const { setNodeRef } = useDroppable({ id });
 
  return (
    <div ref={setNodeRef} className="w-72 bg-gray-100 rounded-lg p-4">
      <h3 className="font-bold mb-3 capitalize">{id}</h3>
      <SortableContext
        items={tasks.map((t) => t.id)}
        strategy={verticalListSortingStrategy}
      >
        <div className="space-y-2 min-h-[100px]">
          {tasks.map((task) => (
            <TaskCard key={task.id} task={task} />
          ))}
        </div>
      </SortableContext>
    </div>
  );
}
 
export default function KanbanBoard() {
  const [columns, setColumns] = useState<Columns>({
    todo: [
      { id: "1", title: "Design mockups" },
      { id: "2", title: "Write tests" },
    ],
    "in-progress": [{ id: "3", title: "Build API" }],
    done: [{ id: "4", title: "Setup repo" }],
  });
  const [activeTask, setActiveTask] = useState<Task | null>(null);
 
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );
 
  function findColumn(taskId: string): string | undefined {
    for (const [columnId, tasks] of Object.entries(columns)) {
      if (tasks.some((t) => t.id === taskId)) return columnId;
    }
    // Check if taskId is a column id (for dropping on empty columns)
    if (columns[taskId]) return taskId;
    return undefined;
  }
 
  function handleDragStart(event: DragStartEvent) {
    const col = findColumn(event.active.id as string);
    if (col) {
      const task = columns[col].find((t) => t.id === event.active.id);
      setActiveTask(task ?? null);
    }
  }
 
  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event;
    if (!over) return;
 
    const activeCol = findColumn(active.id as string);
    const overCol = findColumn(over.id as string);
 
    if (!activeCol || !overCol || activeCol === overCol) return;
 
    setColumns((prev) => {
      const activeItems = [...prev[activeCol]];
      const overItems = [...prev[overCol]];
      const activeIndex = activeItems.findIndex((t) => t.id === active.id);
      const [movedTask] = activeItems.splice(activeIndex, 1);
      const overIndex = overItems.findIndex((t) => t.id === over.id);
      overItems.splice(overIndex >= 0 ? overIndex : overItems.length, 0, movedTask);
 
      return { ...prev, [activeCol]: activeItems, [overCol]: overItems };
    });
  }
 
  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    setActiveTask(null);
    if (!over) return;
 
    const activeCol = findColumn(active.id as string);
    const overCol = findColumn(over.id as string);
    if (!activeCol || !overCol || activeCol !== overCol) return;
 
    const items = columns[activeCol];
    const oldIndex = items.findIndex((t) => t.id === active.id);
    const newIndex = items.findIndex((t) => t.id === over.id);
    if (oldIndex !== newIndex) {
      setColumns((prev) => ({
        ...prev,
        [activeCol]: arrayMove(prev[activeCol], oldIndex, newIndex),
      }));
    }
  }
 
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="flex gap-4 p-6">
        {Object.entries(columns).map(([id, tasks]) => (
          <Column key={id} id={id} tasks={tasks} />
        ))}
      </div>
      <DragOverlay>
        {activeTask ? (
          <div className="p-3 bg-white border rounded shadow-lg rotate-3">
            {activeTask.title}
          </div>
        ) : null}
      </DragOverlay>
    </DndContext>
  );
}

What this demonstrates:

  • Multi-container drag and drop (kanban-style columns)
  • DragOverlay for a custom drag preview
  • Cross-container item movement via onDragOver
  • Within-container reordering via onDragEnd
  • Activation constraint to prevent accidental drags
  • Keyboard accessibility with KeyboardSensor

Deep Dive

How It Works

  • DndContext is the root provider that manages drag state, sensors, and collision detection
  • Sensors (PointerSensor, KeyboardSensor, TouchSensor) determine how drag interactions are initiated and tracked
  • useSortable combines useDraggable and useDroppable into a single hook for sortable items
  • SortableContext tracks the order of items and provides the sorting strategy (vertical, horizontal, or grid)
  • Collision detection algorithms (closestCenter, closestCorners, rectIntersection) determine which droppable target is active during a drag
  • DragOverlay renders a floating element that follows the cursor, separate from the DOM position of the dragged item
  • arrayMove is a utility that reorders an array by moving an element from one index to another

Variations

Horizontal sortable list:

import { horizontalListSortingStrategy } from "@dnd-kit/sortable";
 
<SortableContext items={items} strategy={horizontalListSortingStrategy}>
  <div className="flex gap-2">
    {items.map((id) => (
      <SortableItem key={id} id={id} />
    ))}
  </div>
</SortableContext>

Drag handle (only part of the item is draggable):

function SortableItemWithHandle({ id }: { id: string }) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });
 
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
 
  return (
    <div ref={setNodeRef} style={style} className="flex items-center gap-2 p-3 border rounded">
      <button {...attributes} {...listeners} className="cursor-grab">

      </button>
      <span>{id}</span>
    </div>
  );
}

Restricting drag axis:

import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
 
<DndContext modifiers={[restrictToVerticalAxis]}>
  {/* ... */}
</DndContext>

TypeScript Notes

  • DragEndEvent, DragStartEvent, DragOverEvent provide typed active and over objects
  • active.id and over.id are UniqueIdentifier (string or number)
  • Cast with as string when your IDs are known to be strings
  • useSortable returns a typed object; destructure only the properties you need
import type { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
 
function handleDragEnd(event: DragEndEvent) {
  const activeId: UniqueIdentifier = event.active.id;
  const overId: UniqueIdentifier | undefined = event.over?.id;
}

Gotchas

  • Items jump on drag start — The dragged item's space collapses, causing layout shift. Fix: Use DragOverlay for the visual preview and keep the original item in place with reduced opacity.

  • Accidental drags on click — Single clicks trigger drag events. Fix: Add activationConstraint: { distance: 8 } to PointerSensor to require 8px of movement before activating.

  • IDs must be unique across all containers — If two items in different columns share an ID, dnd-kit cannot determine the correct container. Fix: Use globally unique IDs (e.g., UUIDs) for all draggable items.

  • SSR hydration mismatch — dnd-kit uses DOM measurements that don't exist on the server. Fix: Ensure the component is marked "use client" and wrap in a client-only boundary if needed.

  • Performance with many items — Rendering hundreds of sortable items causes lag. Fix: Use virtualization (e.g., react-virtual) and only wrap visible items in useSortable.

Alternatives

LibraryBest ForTrade-off
dnd-kitModern, accessible, composable DnDRequires more setup for complex cases
react-beautiful-dndSimple sortable listsDeprecated, no longer maintained
react-dndComplex custom DnD interactionsSteeper learning curve, HTML5 backend limitations
@hello-pangea/dndDrop-in react-beautiful-dnd replacementFork, community-maintained
Native HTML dragSimple drag without librariesNo keyboard accessibility, limited control

FAQs

What are the core packages I need to install for a sortable list?
  • @dnd-kit/core -- provides DndContext, sensors, and collision detection
  • @dnd-kit/sortable -- provides SortableContext, useSortable, and arrayMove
  • @dnd-kit/utilities -- provides CSS.Transform.toString for applying transforms
  • Install all three for a sortable list: npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
What is the role of DndContext and why is it required?
  • DndContext is the root provider that manages all drag-and-drop state
  • It coordinates sensors, collision detection, and event handlers
  • All draggable and droppable elements must be descendants of DndContext
  • It provides onDragStart, onDragOver, and onDragEnd callbacks
How does useSortable differ from using useDraggable and useDroppable separately?
  • useSortable combines both hooks into one for items that are both draggable and droppable
  • It handles the sorting transform and transition automatically
  • Use useDraggable + useDroppable separately only when items are draggable but not sortable
Gotcha: Why do my items jump around when I start dragging?
  • The dragged item's space collapses when removed from flow, causing layout shift
  • Use DragOverlay to render a floating preview that follows the cursor
  • Keep the original item in place with opacity: 0.5 while dragging
  • DragOverlay renders outside the normal DOM flow and avoids layout shifts
How do I prevent accidental drags when clicking on items?
const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: { distance: 8 },
  })
);

The distance: 8 constraint requires 8px of movement before a drag activates.

How do I implement a drag handle so only part of the item is draggable?
function SortableItem({ id }: { id: string }) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });
 
  return (
    <div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }}>
      <button {...attributes} {...listeners}>Drag handle</button>
      <span>{id}</span>
    </div>
  );
}

Apply attributes and listeners to the handle element, not the container.

How do I restrict dragging to only the vertical axis?
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
 
<DndContext modifiers={[restrictToVerticalAxis]}>
  {/* ... */}
</DndContext>
How do I type the DragEndEvent handler in TypeScript?
import type { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
 
function handleDragEnd(event: DragEndEvent) {
  const activeId: UniqueIdentifier = event.active.id;
  const overId: UniqueIdentifier | undefined = event.over?.id;
}

UniqueIdentifier is string | number. Cast with as string if your IDs are always strings.

Gotcha: Why does dnd-kit break with SSR or cause hydration mismatches?
  • dnd-kit uses DOM measurements that do not exist during server-side rendering
  • Ensure components using dnd-kit are marked "use client"
  • If you see hydration errors, wrap the dnd-kit component in a client-only boundary
What collision detection algorithms are available and when should I use each?
  • closestCenter -- best for single-container sortable lists
  • closestCorners -- best for multi-container setups (kanban boards)
  • rectIntersection -- triggers when rectangles overlap, good for grid layouts
  • Choose based on your layout; closestCenter is the most common default
Why must item IDs be unique across all containers in a multi-container setup?
  • dnd-kit identifies items globally by their ID
  • Duplicate IDs across containers cause dnd-kit to pick the wrong container
  • Use UUIDs or prefix IDs with the container name to guarantee uniqueness
How do I handle performance with hundreds of sortable items?
  • Rendering many useSortable hooks causes noticeable lag
  • Use virtualization (e.g., @tanstack/react-virtual) to render only visible items
  • Only wrap visible items with useSortable
  • Reduce the sensor update frequency if needed
  • React Patterns — Component composition patterns for DnD
  • React Hooks — Custom hooks used with dnd-kit
  • Zustand — External state management for complex DnD state