Tips & Tricks (Updated: 6/2/2026)

Implement Drag and Drop with Claude Code: React and dnd-kit Guide

Build accessible React drag and drop with Claude Code, dnd-kit, runnable TypeScript code, pitfalls, and review notes.

Implement Drag and Drop with Claude Code: React and dnd-kit Guide

Drag and drop looks simple until you ship it.

A card moving on screen is only one part of the feature. A production UI also needs touch behavior, keyboard operation, screen-reader instructions, empty drop zones, persistence after the final drop, and a way to recover from mistakes. Claude Code can generate a first pass quickly, but the result is much better when you give it a clear implementation contract.

This guide builds a runnable React + TypeScript kanban board with dnd-kit. It also explains when to use the native HTML Drag and Drop API, how to prompt Claude Code, which accessibility checks matter, and how to turn the article into a product or consulting CTA.

Design First

Before asking Claude Code for code, define the data and the interaction model.

TermPlain meaningIn this sample
Drag sourceThe thing users moveA task card
Drop targetThe place users can put itA column or another card
SensorInput detectorPointer and keyboard sensors
StateThe trusted orderReact useState

The mental model is:

User input → sensor → DndContext event → React state update → columns rerender

Do not treat DOM order as the source of truth. In React, the board data should be correct first, and the UI should follow from that data.

Practical Use Cases

Use caseExampleReview focus
Sortable listReading queue, task priority, article orderSave only after drop
Kanban boardSales pipeline, engineering tickets, hiring flowSupport empty columns
File uploadImages, PDFs, CSV importValidate type and size
Dashboard editorReorder widgets and chartsSeparate edit mode on mobile
Form builderMove form fieldsProtect required fields

The kanban board is a useful baseline because it includes same-column sorting, cross-column movement, empty drop zones, and keyboard operation.

Native API or dnd-kit

The browser-native API documented by MDN: HTML Drag and Drop API is still useful for file drops and data transfer from outside the page. For React app interfaces, dnd-kit is usually easier to make predictable and accessible.

OptionBest forWeak point
HTML Drag and Drop APIFile drop zones and external data transferFine-grained React sorting and touch behavior are awkward
dnd-kitReact lists, boards, grids, and accessible interactionsYou must understand sensors and contexts

This sample follows the official dnd-kit getting started guide and dnd-kit accessibility guide. For file upload screens, combine this article with the internal file upload guide and MDN.

Prompt for Claude Code

Give Claude Code constraints, not just a feature name.

{
  "goal": "Build an accessible React drag-and-drop kanban board with TypeScript.",
  "stack": ["React", "TypeScript", "@dnd-kit/core", "@dnd-kit/sortable"],
  "requirements": [
    "Support pointer and keyboard operation",
    "Move cards within a column and across columns",
    "Keep React state as the source of truth",
    "Show visible focus styles",
    "Do not persist data until drag end"
  ],
  "verification": [
    "Move a card with mouse or trackpad",
    "Move a card with keyboard",
    "Drop into an empty column",
    "Cancel a drag with Escape"
  ]
}

For related foundations, see React development with Claude Code, accessibility implementation, and testing strategies.

Setup

npm create vite@latest dnd-demo -- --template react-ts
cd dnd-demo
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm run dev

Data Model

Create src/taskModel.ts.

export type ColumnId = "backlog" | "doing" | "done";

export type Task = {
  id: string;
  title: string;
  note: string;
};

export type BoardState = Record<ColumnId, Task[]>;

export const columnOrder: ColumnId[] = ["backlog", "doing", "done"];

export const columnLabels: Record<ColumnId, string> = {
  backlog: "Backlog",
  doing: "Doing",
  done: "Done",
};

export const initialBoard: BoardState = {
  backlog: [
    { id: "task-1", title: "Write acceptance criteria", note: "Clarify done state before coding." },
    { id: "task-2", title: "Create upload fallback", note: "Keep file input for keyboard users." },
  ],
  doing: [
    { id: "task-3", title: "Build sortable cards", note: "Use dnd-kit sensors and state updates." },
  ],
  done: [
    { id: "task-4", title: "Check MDN guidance", note: "Confirm native drag events and file behavior." },
  ],
};

React Component

Replace src/App.tsx with this complete component.

import { useRef, useState, type CSSProperties } from "react";
import {
  DndContext,
  KeyboardSensor,
  PointerSensor,
  closestCorners,
  useDroppable,
  useSensor,
  useSensors,
  type DragEndEvent,
  type DragOverEvent,
  type DragStartEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import "./App.css";
import {
  columnLabels,
  columnOrder,
  initialBoard,
  type BoardState,
  type ColumnId,
  type Task,
} from "./taskModel";

function isColumnId(value: string): value is ColumnId {
  return columnOrder.includes(value as ColumnId);
}

function findContainer(itemId: string, board: BoardState): ColumnId | null {
  if (isColumnId(itemId)) {
    return itemId;
  }

  return (
    columnOrder.find((columnId) =>
      board[columnId].some((task) => task.id === itemId),
    ) ?? null
  );
}

export default function App() {
  const [board, setBoard] = useState<BoardState>(initialBoard);
  const [activeId, setActiveId] = useState<string | null>(null);
  const sourceColumnRef = useRef<ColumnId | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 6 },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  function handleDragStart(event: DragStartEvent) {
    const taskId = String(event.active.id);
    setActiveId(taskId);
    sourceColumnRef.current = findContainer(taskId, board);
  }

  function handleDragOver(event: DragOverEvent) {
    const { active, over } = event;

    if (!over) {
      return;
    }

    const activeTaskId = String(active.id);
    const overId = String(over.id);

    setBoard((currentBoard) => {
      const activeColumn = findContainer(activeTaskId, currentBoard);
      const overColumn = findContainer(overId, currentBoard);

      if (!activeColumn || !overColumn || activeColumn === overColumn) {
        return currentBoard;
      }

      const activeTask = currentBoard[activeColumn].find(
        (task) => task.id === activeTaskId,
      );

      if (!activeTask) {
        return currentBoard;
      }

      const nextActiveItems = currentBoard[activeColumn].filter(
        (task) => task.id !== activeTaskId,
      );
      const overItems = currentBoard[overColumn];
      const overIndex = overItems.findIndex((task) => task.id === overId);
      const insertIndex = overIndex >= 0 ? overIndex : overItems.length;

      return {
        ...currentBoard,
        [activeColumn]: nextActiveItems,
        [overColumn]: [
          ...overItems.slice(0, insertIndex),
          activeTask,
          ...overItems.slice(insertIndex),
        ],
      };
    });
  }

  function handleDragEnd(event: DragEndEvent) {
    const startedIn = sourceColumnRef.current;
    sourceColumnRef.current = null;
    setActiveId(null);

    const { active, over } = event;

    if (!over || active.id === over.id) {
      return;
    }

    const activeTaskId = String(active.id);
    const overId = String(over.id);

    setBoard((currentBoard) => {
      const activeColumn = findContainer(activeTaskId, currentBoard);
      const overColumn = findContainer(overId, currentBoard);

      if (!activeColumn || !overColumn || activeColumn !== overColumn) {
        return currentBoard;
      }

      if (startedIn && startedIn !== activeColumn) {
        return currentBoard;
      }

      const columnTasks = currentBoard[activeColumn];
      const oldIndex = columnTasks.findIndex((task) => task.id === activeTaskId);
      const newIndex = columnTasks.findIndex((task) => task.id === overId);

      if (oldIndex < 0 || newIndex < 0) {
        return currentBoard;
      }

      return {
        ...currentBoard,
        [activeColumn]: arrayMove(columnTasks, oldIndex, newIndex),
      };
    });
  }

  function handleDragCancel() {
    sourceColumnRef.current = null;
    setActiveId(null);
  }

  return (
    <main className="appShell">
      <header className="appHeader">
        <div>
          <p className="eyebrow">Claude Code demo</p>
          <h1>Accessible drag-and-drop board</h1>
        </div>
        <p id="dnd-help" className="helpText">
          Use the handle on each card. Keyboard: Tab to a handle, Space or Enter to lift,
          arrow keys to move, Space or Enter to drop, Escape to cancel.
        </p>
      </header>

      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragStart={handleDragStart}
        onDragOver={handleDragOver}
        onDragEnd={handleDragEnd}
        onDragCancel={handleDragCancel}
      >
        <div className="board" aria-describedby="dnd-help">
          {columnOrder.map((columnId) => (
            <KanbanColumn
              key={columnId}
              columnId={columnId}
              tasks={board[columnId]}
              activeId={activeId}
            />
          ))}
        </div>
      </DndContext>
    </main>
  );
}

function KanbanColumn({
  columnId,
  tasks,
  activeId,
}: {
  columnId: ColumnId;
  tasks: Task[];
  activeId: string | null;
}) {
  const { setNodeRef, isOver } = useDroppable({ id: columnId });

  return (
    <section
      ref={setNodeRef}
      className={`column ${isOver ? "columnOver" : ""}`}
      aria-labelledby={`heading-${columnId}`}
    >
      <h2 id={`heading-${columnId}`}>{columnLabels[columnId]}</h2>
      <SortableContext
        items={tasks.map((task) => task.id)}
        strategy={verticalListSortingStrategy}
      >
        <div className="taskList">
          {tasks.map((task) => (
            <SortableTask key={task.id} task={task} isActive={activeId === task.id} />
          ))}
          {tasks.length === 0 ? <p className="emptyState">Drop a task here</p> : null}
        </div>
      </SortableContext>
    </section>
  );
}

function SortableTask({ task, isActive }: { task: Task; isActive: boolean }) {
  const {
    attributes,
    listeners,
    setActivatorNodeRef,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: task.id });

  const style: CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <article
      ref={setNodeRef}
      style={style}
      className={`taskCard ${isDragging || isActive ? "taskCardDragging" : ""}`}
    >
      <button
        ref={setActivatorNodeRef}
        type="button"
        className="dragHandle"
        aria-label={`Move ${task.title}`}
        {...attributes}
        {...listeners}
      >
        <span aria-hidden="true">↕</span>
      </button>
      <div>
        <h3>{task.title}</h3>
        <p>{task.note}</p>
      </div>
    </article>
  );
}

CSS

Replace src/App.css.

:root {
  color: #172033;
  background: #f6f7fb;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
}

body {
  margin: 0;
}

button {
  font: inherit;
}

.appShell {
  min-height: 100vh;
  padding: 32px;
}

.appHeader {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(280px, 460px);
  gap: 24px;
  align-items: end;
  margin-bottom: 24px;
}

.eyebrow {
  margin: 0 0 8px;
  color: #4f46e5;
  font-size: 0.78rem;
  font-weight: 700;
  text-transform: uppercase;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 0;
  font-size: clamp(2rem, 4vw, 3.5rem);
}

.helpText {
  margin-bottom: 0;
  color: #526070;
  line-height: 1.6;
}

.board {
  display: grid;
  grid-template-columns: repeat(3, minmax(220px, 1fr));
  gap: 16px;
}

.column {
  min-height: 420px;
  padding: 16px;
  border: 1px solid #d9deea;
  border-radius: 8px;
  background: #ffffff;
}

.columnOver {
  outline: 3px solid #93c5fd;
  outline-offset: 2px;
}

.taskList {
  display: grid;
  gap: 12px;
  min-height: 120px;
}

.taskCard {
  display: grid;
  grid-template-columns: 44px minmax(0, 1fr);
  gap: 12px;
  align-items: start;
  padding: 14px;
  border: 1px solid #d9deea;
  border-radius: 8px;
  background: #ffffff;
  box-shadow: 0 8px 24px rgba(23, 32, 51, 0.08);
}

.taskCardDragging {
  opacity: 0.58;
}

.taskCard h3 {
  margin-bottom: 4px;
  font-size: 1rem;
}

.taskCard p {
  margin-bottom: 0;
  color: #526070;
  line-height: 1.5;
}

.dragHandle {
  display: grid;
  width: 36px;
  height: 36px;
  place-items: center;
  border: 1px solid #c8d0df;
  border-radius: 8px;
  background: #f8fafc;
  color: #263244;
  cursor: grab;
}

.dragHandle:active {
  cursor: grabbing;
}

.dragHandle:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

.emptyState {
  display: grid;
  min-height: 88px;
  place-items: center;
  border: 1px dashed #b9c2d3;
  border-radius: 8px;
  color: #6b7280;
}

@media (max-width: 760px) {
  .appShell {
    padding: 20px;
  }

  .appHeader,
  .board {
    grid-template-columns: 1fr;
  }
}

@media (prefers-reduced-motion: reduce) {
  .taskCard {
    transition: none;
  }
}

Accessibility Checklist

Check the feature before publishing:

  • Every card handle is reachable with Tab.
  • Space or Enter starts and finishes a keyboard drag.
  • Arrow keys move the lifted item.
  • Escape cancels the operation.
  • Focus styles are visible.
  • The board has instructions via aria-describedby.
  • Motion is reduced when users request reduced motion.
  • File upload screens keep a real <input type="file"> fallback.

These checks are not polish. They change who can use the feature.

Common Pitfalls

The first pitfall is persisting on every drag-over event. Drag events fire many times, so save after the final drop unless you have a carefully designed real-time collaboration flow.

The second pitfall is forgetting empty columns. A kanban board that cannot drop into an empty lane breaks during normal work.

The third pitfall is mouse-only design. Use pointer and keyboard sensors, expose a real button as the drag handle, and test on a narrow viewport.

The fourth pitfall is asking Claude Code for “nice drag and drop” without constraints. Tell it which library to use, which keyboard behavior to preserve, and what not to change.

CTA and Next Steps

Drag-and-drop screens are strong conversion points because readers usually have an immediate product need. If you are standardizing this across a team, pair the implementation with a CLAUDE.md rule set. See CLAUDE.md templates, performance optimization, and Claude Code training.

ClaudeCodeLab also offers templates and products for teams that want repeatable Claude Code workflows rather than one-off prompts.

Hands-on Verification Note

In practice, the best results came from deciding the BoardState shape before prompting Claude Code. Adding empty-column drops, keyboard movement, and Escape cancellation to the acceptance checklist caught the “looks fine with a mouse only” version early. The same pattern works for image ordering, dashboard widgets, and file-upload review queues: define state, persistence timing, and keyboard alternatives first.

Summary

For React app interfaces, use dnd-kit when you need accessible sorting and cross-column movement. Use the native HTML Drag and Drop API when the browser needs to receive external files or data. With Claude Code, the quality comes from the contract: data shape, sensors, keyboard behavior, persistence timing, and review checks.

#Claude Code #drag and drop #React #UI #accessibility
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.