React ドラッグ&ドロップ実装、dnd-kitでキーボード操作まで通す
React のドラッグ&ドロップを dnd-kit で実装。HTML5 Drag and Drop APIの限界、リスト並び替え、キーボード操作とタッチ対応まで、コピペで動くコード付き。
「カードをつかんで別の列に移すだけでしょ? 半日で終わる」
そう見積もって、僕は丸2日溶かしました。マウスでは気持ちよく動く。デモ動画もバッチリ撮れた。でも社内レビューで「キーボードだけで使える?」と聞かれて固まったんです。Tabキーを押してもカードにフォーカスが当たらない。スマホで触ったら、ドラッグしたいのにページごとスクロールしてしまう。
ドラッグ&ドロップは、見た目の派手さと実装の地味さのギャップが、フロントエンドでいちばん大きいUIだと思います。動いて見えるものと、誰でも使えるものの距離が遠い。
この記事では、React + TypeScript で並び替え可能なカンバンボードを dnd-kit で作ります。最初に「ブラウザ標準のAPIでなぜ詰むのか」を押さえて、そこから dnd-kit で列内並び替え・列をまたぐ移動・空の列への投下・キーボード操作・タッチ対応まで一気に通します。コードはそのままコピペで動く形で置きます。
この記事の要点
- React のリスト並び替えやカンバンを HTML5 Drag and Drop API だけで作ると、タッチ非対応・キーボード非対応・座標計算の地獄にハマる。素直に
dnd-kitを使う。 - ファイルをデスクトップから投げ込む用途だけはブラウザ標準APIが今でも有効。アプリ内の並び替えは dnd-kit、と使い分ける。
- 並び順の正解は DOM ではなく React の state に置く。DOM を直接いじると必ず破綻する。
PointerSensorとKeyboardSensorを両方入れるだけで、マウス・タッチ・キーボードの3経路に同時対応できる。- 保存はドラッグ中ではなく「ドロップ確定後」に1回だけ。ここを外すとAPIが悲鳴を上げる。
まずブラウザ標準APIの限界を知る
ブラウザには MDN の HTML Drag and Drop API で解説されている、draggable 属性と ondragstart / ondrop などのイベントを使う仕組みがあります。追加ライブラリなしで動くので、最初はこれで十分に見えます。
でも、Reactでリストの並び替えを作ろうとすると、すぐに3つの壁にぶつかります。
ひとつ目は タッチ端末で動かないこと。dragstart などの一連のイベントは、スマホやタブレットの指の操作では基本的に発火しません。スマホ対応が前提の今、これは致命的です。
ふたつ目は キーボードで操作できないこと。マウスのドラッグ前提なので、キーボードだけを使う人には「並び替え」という機能自体が存在しないのと同じになります。
みっつ目は 並び替えの座標計算が自前になること。「どのカードのどの位置に落ちたか」を、getBoundingClientRect() で要素の座標を取りながら手計算する羽目になります。隙間にプレースホルダーを挟む処理まで含めると、コードはあっという間に数百行に膨れます。
| 選択肢 | 向いている場面 | 弱いところ |
|---|---|---|
| HTML Drag and Drop API | ファイルのドロップ、外部アプリとのデータ受け渡し | タッチ非対応、キーボード非対応、並び替えの制御が重い |
| dnd-kit | React の並び替え、リスト、カンバン、アクセシブルな操作 | ライブラリの概念を最初に覚える必要がある |
結論はシンプルで、ファイルアップロードだけは標準API、アプリ内の並び替えは dnd-kit と割り切るのが、僕の実務でいちばん安定しています。
なぜ dnd-kit を選ぶのか
dnd-kit は React 専用のドラッグ&ドロップ用ライブラリです。dnd-kit 公式の Getting started を読むと設計思想が分かりますが、ありがたいのは、さっきの3つの壁を最初から潰してくれている点です。
- センサーという仕組みで、マウス・タッチ・キーボードを差し替え可能な「入力経路」として扱える
- 並び替え用の
@dnd-kit/sortableが、隙間の計算やアニメーションを肩代わりしてくれる - フォーカス管理やスクリーンリーダー向けのアナウンスが組み込まれている
覚える言葉は最初4つだけで足ります。ここを押さえると、あとのコードがぜんぶ読めます。
| 用語 | 平たく言うと | 実装で見る場所 |
|---|---|---|
| draggable(ドラッグ元) | つかんで動かす要素 | タスクカード |
| droppable(ドロップ先) | 置ける場所 | 列、カードの前後 |
| sensor(センサー) | 入力方法を検知する係 | マウス、タッチ、キーボード |
| state | 画面の正しい並び順 | React の useState |
頭の中の流れはこうです。
ユーザー操作 → センサーが検知 → DndContext がイベント化 → React state を更新 → 列が再描画される
ここで一番大事なのは、DOM を直接並び替えないこと。「画面の順番を動かす」のではなく、「データの順番を変えた結果、画面が勝手に追従する」と考える。Reactの基本ですが、ドラッグ&ドロップだとつい忘れて、後で必ず痛い目を見ます。
セットアップとデータ構造
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本の配列で持つより、列ごとに配列を分けるほうが、列をまたぐ移動を書くとき圧倒的に追いやすいです。
// 列のID。型で縛っておくと、存在しない列を参照したときに気づける
export type ColumnId = "backlog" | "doing" | "done";
export type Task = {
id: string;
title: string;
note: string;
};
// 列IDをキーに、その列のタスク配列を持つ
export type BoardState = Record<ColumnId, Task[]>;
export const columnOrder: ColumnId[] = ["backlog", "doing", "done"];
export const columnLabels: Record<ColumnId, string> = {
backlog: "未着手",
doing: "作業中",
done: "完了",
};
export const initialBoard: BoardState = {
backlog: [
{ id: "task-1", title: "完了条件を書く", note: "コードを書く前にゴールを言語化する" },
{ id: "task-2", title: "アップロードの代替を用意", note: "キーボード用にfile入力を残す" },
],
doing: [
{ id: "task-3", title: "並び替えカードを作る", note: "dnd-kitのセンサーとstate更新で組む" },
],
done: [
{ id: "task-4", title: "MDNの記述を確認", note: "標準ドラッグイベントとファイル挙動を確認" },
],
};
コピペで動くカンバンボード
src/App.tsx をまるごと次に置き換えます。長く見えますが、役割は4つに分かれているだけです。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";
// 受け取ったIDが「列のID」かどうかを型安全に判定する
function isColumnId(value: string): value is ColumnId {
return columnOrder.includes(value as ColumnId);
}
// あるアイテムが今どの列にいるかを探す。列IDそのものなら即返す
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);
// マウス/タッチ=Pointer、キーボード=Keyboard。両方登録するのが肝
const sensors = useSensors(
useSensor(PointerSensor, {
// 6px動くまでドラッグ開始しない。タッチのスクロール誤爆を防ぐ
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),
};
// ここがドロップ確定。保存APIを呼ぶならこのタイミングで1回だけ
});
}
function handleDragCancel() {
sourceColumnRef.current = null;
setActiveId(null);
}
return (
<main className="appShell">
<header className="appHeader">
<div>
<p className="eyebrow">dnd-kit demo</p>
<h1>アクセシブルな並び替えボード</h1>
</div>
<p id="dnd-help" className="helpText">
各カードのハンドルを使います。キーボード:Tabでハンドルへ、SpaceかEnterで持ち上げ、
矢印キーで移動、SpaceかEnterで置く、Escapeで取り消し。
</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;
}) {
// 空でも置けるように、列自体をdroppableにするのが空カラム対応の正体
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">ここにドロップ</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。これだけでキーボードフォーカスが当たる */}
<button
ref={setActivatorNodeRef}
type="button"
className="dragHandle"
aria-label={`${task.title} を移動`}
{...attributes}
{...listeners}
>
<span aria-hidden="true">↕</span>
</button>
<div>
<h3>{task.title}</h3>
<p>{task.note}</p>
</div>
</article>
);
}
ハンドルを <button> にしているのが地味に効いています。<div> に onClick を載せる流派もありますが、ボタンにするだけでキーボードのフォーカスが自動で当たり、スクリーンリーダーも「ボタン」と読んでくれる。自前で tabIndex を足す必要がなくなります。
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でアニメーションが止まる
タッチ対応のキモは、コードに入れた activationConstraint: { distance: 6 } です。これは「指が6px動くまではドラッグを始めない」という設定で、入れないとスクロールしようとしただけでカードを掴んでしまう事故が起きます。逆に大きくしすぎると「掴みにくい」と言われるので、6〜10pxあたりで実機を見ながら調整します。
dnd-kit のアクセシビリティの考え方は dnd-kit Accessibility ガイド にまとまっています。スクリーンリーダー向けのアナウンス文を日本語にカスタマイズしたいときは、ここを読むと早いです。アクセシビリティ全般の進め方は Claude Codeでアクセシビリティ対応 も合わせてどうぞ。
ファイルのドラッグ投下を作る場合は、ドラッグ操作だけに依存しないでください。必ず <input type="file"> を残し、キーボードでも選べるようにします。実装の詳細は ファイルアップロード実装 に分けて書いています。
僕がハマった落とし穴4つ
正直に書きます。最初の実装はバグの見本市でした。
ひとつ目は、ドラッグ中に毎回サーバー保存したこと。onDragOver はドラッグ中に何度も発火します。そこで保存APIを呼んだら、1回のドラッグで数十リクエストが飛び、サーバーのログが真っ赤になりました。保存はドロップ確定(handleDragEnd の中)で1回だけ。途中経過は React state にしか書きません。
ふたつ目は、空の列に落とせなかったこと。カードが1枚もない列に置けないと、実務のカンバンでは即詰まります。原因は、列の中のカードだけを droppable にして、列そのものを置き場所にしていなかったこと。useDroppable({ id: columnId }) を列に付けて解決しました。
みっつ目は、並び順を DOM から読もうとしたこと。並び替えた後、保存のために querySelectorAll でカードを上から拾って順番を作っていました。これが React の再描画とズレて、保存した順番が画面と違う、という気持ち悪いバグになりました。順番の正解は常に board という state。保存もそこから作ります。
よっつ目は、Claude Code に「いい感じのUIを作って」と丸投げしたこと。一見動くコードはすぐ出ます。でもキーボード非対応で、保存タイミングもバラバラでした。「dnd-kit を使う」「キーボード対応」「空の列に対応」「保存は drag end 後」と制約を渡したら、レビューしやすい差分に変わりました。下のJSONを丸ごとタスク説明として貼ると安定します。
{
"goal": "アクセシブルなReactのドラッグ&ドロップ・カンバンをTypeScriptで作る",
"stack": ["React", "TypeScript", "@dnd-kit/core", "@dnd-kit/sortable"],
"requirements": [
"ポインタとキーボードの両方で操作できる",
"列内の並び替えと列をまたぐ移動に対応する",
"並び順の正解はReactのstateに置く",
"フォーカスリングを見える状態にする",
"保存はドロップ確定後にだけ行う"
],
"verification": [
"マウスかトラックパッドでカードを動かす",
"キーボードだけでカードを動かす",
"空の列にドロップする",
"Escapeでドラッグを取り消す"
]
}
プロンプトの組み立て方は 伝わるプロンプトの書き方、土台になるReactの作法は Claude CodeでReact開発 が参考になります。CLAUDE.md に「アクセシビリティ」「保存タイミング」「DOM直接操作の禁止」を先に書いておくと、出力のブレがさらに減ります(CLAUDE.mdベストプラクティス)。
よくある質問
Q. react-beautiful-dnd ではダメですか? かつての定番でしたが現在はメンテナンスが止まっており、新規実装で選ぶ理由は薄いです。React 18以降の並び替えなら dnd-kit が無難で、キーボードとタッチの対応も最初から入っています。
Q. ライブラリを使わず HTML5 標準APIだけで並び替えは無理ですか? 不可能ではありませんが、タッチ非対応とキーボード非対応を自前で埋める必要があり、コード量が跳ね上がります。ファイルの投下だけ標準API、アプリ内の並び替えは dnd-kit、と分けるのが現実的です。
Q. 並び替えた順番をサーバーに保存したいです。どこで送れば?
handleDragEnd の中、state を更新した直後です。更新後の board の各列の配列順がそのまま保存すべき順番なので、それをAPIに送ります。ドラッグ中(onDragOver)で送ると過剰リクエストになります。
Q. スマホでドラッグしようとするとページがスクロールしてしまいます。
PointerSensor の activationConstraint: { distance: 6 } を設定してください。一定距離動くまでドラッグを開始しないので、スクロールとの誤爆が止まります。値は実機で6〜10px程度に調整します。
Q. ドラッグ中のカードのプレビュー(ゴースト)をきれいに出したいです。
dnd-kit の DragOverlay を使うと、元の位置とは独立した見た目のプレビューを描けます。今回のサンプルは最小構成のため省いていますが、本番では入れると掴んでいる感がぐっと上がります。
実際に試した結果
冒頭で2日溶かして以来、僕は順番を逆にしました。ライブラリやCSSより先に、state の形・保存タイミング・キーボードの代替操作を決める。この3つを紙に書いてから dnd-kit に手を付けたら、列をまたぐ移動も空の列への投下も、ほぼ一発で通りました。
特に効いたのは、activationConstraint のタッチ誤爆対策と、ハンドルを <button> にするだけのキーボード対応です。派手な機能ではないのに、この2つを外すと「公開できない」判定になる。逆に言えば、ここさえ押さえれば残りは応用です。
このカンバンを土台に、タスク管理、記事の表示順の編集、画像ギャラリーの並び替え、営業案件のボードへそのまま展開できます。テストの観点は テスト戦略ガイド にまとめてあるので、公開前のチェックリストづくりに使ってください。実装ルールをチームで標準化したいなら テンプレート/教材 もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。