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.
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.
| Term | Plain meaning | In this sample |
|---|---|---|
| Drag source | The thing users move | A task card |
| Drop target | The place users can put it | A column or another card |
| Sensor | Input detector | Pointer and keyboard sensors |
| State | The trusted order | React 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 case | Example | Review focus |
|---|---|---|
| Sortable list | Reading queue, task priority, article order | Save only after drop |
| Kanban board | Sales pipeline, engineering tickets, hiring flow | Support empty columns |
| File upload | Images, PDFs, CSV import | Validate type and size |
| Dashboard editor | Reorder widgets and charts | Separate edit mode on mobile |
| Form builder | Move form fields | Protect 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.
| Option | Best for | Weak point |
|---|---|---|
| HTML Drag and Drop API | File drop zones and external data transfer | Fine-grained React sorting and touch behavior are awkward |
| dnd-kit | React lists, boards, grids, and accessible interactions | You 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.