Claude Codeでドラッグ&ドロップを実装する完全ガイド
Claude CodeでReactのドラッグ&ドロップを安全に実装する手順を、dnd-kitコード・アクセシビリティ・落とし穴付きで解説。
ドラッグ&ドロップは、見た目よりも失敗しやすいUIです。
「カードをつかんで移動するだけ」に見えても、実際にはマウス、タッチ、キーボード、スクリーンリーダー、並べ替え後の保存、空カラムへのドロップ、誤操作の取り消しまで考える必要があります。Claude Codeに丸投げすると一見動くコードはすぐ出ますが、公開できる品質にするには、最初に条件を絞って渡すことが大事です。
この記事では、React + TypeScript + dnd-kitで動くドラッグ&ドロップのカンバンボードを作ります。初心者向けに、ドラッグ元、ドロップ先、センサー、状態更新の意味から説明し、Claude Codeへ渡す指示、実装コード、アクセシビリティ確認、落とし穴、収益導線までまとめます。
先に決めること
ドラッグ&ドロップの実装では、最初に「何を動かすのか」と「どこで保存するのか」を決めます。ここが曖昧だと、Claude CodeはDOM操作だけで並べ替えるコードや、キーボードで操作できないカードを生成しがちです。
初心者は次の4つの言葉だけ押さえれば十分です。
| 用語 | 平易な意味 | 実装で見る場所 |
|---|---|---|
| ドラッグ元 | つかんで動かす要素 | タスクカード |
| ドロップ先 | 置ける場所 | カラム、カードの前後 |
| sensor | 入力方法を検知する係 | マウス、タッチ、キーボード |
| state | 画面の正しい並び順 | ReactのuseState |
概念図としては、次の流れです。
ユーザー操作 → sensorが検知 → DndContextがイベント化 → React stateを更新 → カラムが再描画される
DOMを直接並べ替えるのではなく、stateを正とするのがポイントです。Reactでは「画面を動かす」のではなく、「データの順番を変えた結果として画面が変わる」と考えると、バグが減ります。
使えるユースケース
Claude Codeで作るドラッグ&ドロップは、単なるデモで終わらせず、業務の画面に落とし込むところまで設計します。代表的なユースケースは次の通りです。
| ユースケース | 例 | 注意点 |
|---|---|---|
| タスクの並べ替え | ToDo、学習リスト、記事の掲載順 | 並べ替え後の保存タイミングを決める |
| カンバンボード | 営業案件、開発チケット、採用候補者 | 空カラムへのドロップを忘れない |
| ファイルアップロード | 画像、PDF、CSVの投入 | ファイルサイズとMIMEタイプを検証する |
| ダッシュボード編集 | ウィジェットやグラフの配置 | モバイルでは編集モードを分ける |
| フォームビルダー | 入力項目の並べ替え | 必須項目を削除できない制御が必要 |
この中で最も再利用しやすいのがカンバンボードです。カラム間移動、同一カラム内の並べ替え、空カラム、キーボード操作を一通り含むため、ほかの用途にも応用できます。
Native APIかdnd-kitか
ブラウザにはMDNのHTML Drag and Drop APIで解説されているネイティブ機能があります。ファイルをデスクトップからブラウザへ落とすような用途では今でも有効です。一方で、Reactのリスト並べ替えやカンバンでは、イベントの差分、タッチ対応、キーボード対応が面倒になります。
| 選択肢 | 向いている場面 | 弱いところ |
|---|---|---|
| HTML Drag and Drop API | ファイルドロップ、外部アプリとのデータ受け渡し | タッチ端末や細かい並べ替えの制御が難しい |
| dnd-kit | Reactのカード移動、リスト、カンバン、アクセシブルな操作 | ライブラリの概念を理解する必要がある |
この記事のサンプルではdnd-kitのGetting startedとdnd-kit Accessibility guideの考え方に沿って、PointerSensorとKeyboardSensorを使います。ファイルアップロードを作る場合はMDNのネイティブAPI、アプリ内の並べ替えはdnd-kit、と使い分けるのが実務では安定します。
Claude Codeへの指示
Claude Codeには「ドラッグ&ドロップを作って」ではなく、入力方法、保存、アクセシビリティ、検証方法まで渡します。たとえば次のJSONをタスク説明として貼ると、レビューしやすい差分になりやすいです。
{
"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"
]
}
関連する基礎はReact開発ガイド、アクセシビリティはClaude Codeアクセシビリティ対応、テスト観点はテスト戦略ガイドも合わせて読むと理解しやすいです。
セットアップ
ViteのReact + TypeScriptテンプレートにdnd-kitを入れます。既存アプリに入れる場合も、依存関係は同じです。
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
データモデル
src/taskModel.tsを作ります。タスクを配列で1つにまとめるより、カラムごとに配列を持つほうが初心者には追いやすいです。
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コンポーネント
src/App.tsxを次の内容に置き換えます。DndContextがドラッグ&ドロップ全体の管理役、SortableContextがカラム内の並べ替え、useDroppableが空カラムも含む置き場所、useSortableが各カードの移動処理です。
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
src/App.cssも置き換えます。特にfocus-visibleとprefers-reduced-motionは、見た目だけでなくアクセシビリティレビューで見られるポイントです。
: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;
}
}
キーボードとアクセシビリティの確認
ドラッグ&ドロップは「マウスで動く」だけでは足りません。公開前に次の観点を確認します。
- Tabキーで各カードのハンドルへ移動できる
- SpaceまたはEnterでカードを持ち上げられる
- 矢印キーで移動し、SpaceまたはEnterでドロップできる
- Escapeで操作をキャンセルできる
- フォーカスリングが見える
- 説明文が
aria-describedbyで操作領域に結びついている - 動きが苦手なユーザー向けに
prefers-reduced-motionへ配慮している
ファイルアップロードの場合は、ドラッグ操作だけに依存しないでください。必ず<input type="file">を残し、キーボードでも選択できるようにします。詳しくはファイルアップロード実装とMDNのHTML Drag and Drop APIを合わせて確認すると安全です。
よくある落とし穴
1つ目は、dragoverやonDragOverで毎回サーバー保存することです。ドラッグ中はイベントが何度も発火するため、保存は原則としてドロップ確定後に行います。途中経過はReact stateだけで扱うほうが安定します。
2つ目は、空のカラムをドロップ先にし忘れることです。カードが1枚もない列に置けないと、実務のカンバンではすぐ詰まります。サンプルではuseDroppable({ id: columnId })をカラムに付けて、この問題を避けています。
3つ目は、マウス専用UIにすることです。タッチ端末ではスクロールとの競合が起き、キーボードユーザーは操作できません。dnd-kitのPointerSensorとKeyboardSensorを両方入れ、ハンドルをbuttonにするのが現実的です。
4つ目は、並び順をDOMから読み取ることです。ReactではDOMではなくstateを正とします。保存するなら、boardの中身をAPIに送る設計にします。
5つ目は、Claude Codeに広すぎる指示を出すことです。「UIをいい感じに」ではなく、「dnd-kitを使う」「キーボード対応」「空カラム対応」「保存はdrag end後」のように制約を渡すほうが、生成コードの品質が安定します。プロンプト設計はプロンプトテクニック集も参考になります。
収益導線に組み込む
ドラッグ&ドロップの記事は、単にUI実装を見せるだけでなく、Claude Code導入支援やテンプレート販売につなげやすいテーマです。読者は「このUIを自分の業務アプリに入れたい」という具体的な需要を持っているからです。
ClaudeCodeLabでは、チーム向けのClaude Code研修・相談と、実装ルールを標準化するテンプレート/教材を用意しています。カンバン、ファイルアップロード、ダッシュボード編集のようなUIを社内プロダクトへ入れる場合は、先にCLAUDE.mdへ「アクセシビリティ」「保存タイミング」「禁止するDOM直接操作」を書いておくと、Claude Codeの出力をレビューしやすくなります。CLAUDE.mdの整備はClaude.mdベストプラクティスも合わせて確認してください。
実際に試した結果
Masaの検証メモとして、このサンプル構成では、最初にカラムごとのBoardStateを決めてからClaude Codeへ依頼したほうが、後から直す箇所が明らかに減りました。特に、空カラムへのドロップ、キーボード操作、Escapeキャンセルをチェックリストに入れておくと、「見た目は動くが公開品質ではない」コードを早い段階で見つけられます。ファイルドロップや画像並べ替えへ応用する場合も、まずstateの形、保存タイミング、キーボード代替操作を決めるのが一番効きました。
まとめ
Claude Codeでドラッグ&ドロップを作るときは、実装ライブラリより先に、データ構造、入力方法、アクセシビリティ、保存タイミングを決めます。Reactの並べ替えUIならdnd-kit、ファイル受け取りならMDNのHTML Drag and Drop APIを基準にすると判断しやすいです。
このサンプルを土台に、タスク管理、記事の表示順編集、画像ギャラリー、営業カンバン、ダッシュボード編集へ展開できます。公開前には、マウス、キーボード、空カラム、モバイル幅、保存失敗時の戻し方まで確認してください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。