dnd-kit v6 ドラッグ & ドロップ実装ガイド
dnd-kit v6 でドラッグ&ドロップ UI を組む実装パターン。sortable list の最小実装、複数カラムを跨ぐ Kanban(列移動の状態管理を含む)、useSensor / DragOverlay の細部までを 1 ページに統合した実装ノート。
dnd-kit v6 でソート可能なリストを作る
dnd-kit v6(@dnd-kit/core + sortable + utilities)でアクセシブルな drag & drop リストを最小コードで実装するパターン、PointerSensor / KeyboardSensor の合成、よく詰まる点を触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
@dnd-kit/core/@dnd-kit/sortable@10.0.0/@dnd-kit/utilities対象: React で並び替え可能なリスト・カンバン・グリッドを作りたい人
dnd-kit v6 で アクセシブル(キーボード対応)な drag & drop リスト を最小コードで組むパターン。useSortable + SortableContext + PointerSensor / KeyboardSensor の合成、よく詰まる点を動く demo で確認します。
触って試す
マウス drag で並び替え。Tab + Space で掴み、矢印キーで移動・Space で離す(キーボード操作も対応)。
なぜ dnd-kit を選ぶか
| 選択肢 | 特徴 |
|---|---|
| dnd-kit | アクセシブル(キーボード対応)、軽量、拡張性高い |
| react-beautiful-dnd | メンテ停止傾向、a11y 強いがリスト系専用 |
| react-dnd | HTML5 DnD ベース、低レベル、慣れが必要 |
| Sortable.js + react-sortablejs | 命令的 API、SSR で罠が多い |
a11y 重視 + sortable / kanban / grid 両対応 + activity が継続している、で dnd-kit。
drag が起こる仕組み(useSortable の内側)
useSortable({ id }) が一見シンプルなのは、内部で DndContext からの broadcast を購読して transform / transition を返しているから。
つまり useSortable は単なる subscriber。DndContext が drag state を持ち、各 sortable item は自分のターン(transform 更新)が来たら反映する、という形。
最小サンプル(ソート可能リスト)
DndContext で範囲を囲み、SortableContext に items 配列を渡し、各 item で useSortable を呼ぶ:
import {
DndContext, closestCenter,
PointerSensor, KeyboardSensor, useSensor, useSensors,
} from "@dnd-kit/core";
import {
SortableContext, arrayMove, sortableKeyboardCoordinates,
useSortable, verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function SortableItem({ id, children }: { id: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
return (
<li
ref={setNodeRef}
{...attributes}
{...listeners}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
{children}
</li>
);
}
export function SortableList() {
const [items, setItems] = useState(["a", "b", "c"]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => {
const { active, over } = e;
if (over && active.id !== over.id) {
setItems((arr) => arrayMove(arr, arr.indexOf(active.id), arr.indexOf(over.id)));
}
}}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
<ul>
{items.map((id) => <SortableItem key={id} id={id}>{id}</SortableItem>)}
</ul>
</SortableContext>
</DndContext>
);
}
sensor の使い分け
PointerSensor: マウス / タッチ統合。activationConstraint: { distance: 5 }で誤検知防止KeyboardSensor: Tab で focus → Space で grab → 矢印で移動 → Space で releaseMouseSensor/TouchSensor: 個別に分けたい時(タッチ専用 UI など)
PointerSensor + KeyboardSensor の 2 つ揃えば、a11y 要件を満たせる。
activationConstraint の決定木
| 設定 | 推奨用途 |
|---|---|
{ distance: 5 } | デスクトップ中心、click と drag の混在 OK |
{ delay: 100, tolerance: 5 } | モバイル中心、長押しで drag 起動 |
| 併用は不可 | distance か delay のいずれか |
strategy の使い分け
| strategy | 用途 |
|---|---|
verticalListSortingStrategy | 縦並びリスト |
horizontalListSortingStrategy | 横並びリスト |
rectSortingStrategy | グリッド |
| (custom) | カンバン(列内 sort + 列間 move) |
カンバンは「複数の SortableContext を束ねる」+ onDragOver で列間 move を捕捉、というパターン。
つまずいたポイント
activationConstraintを入れないとボタンクリックを drag と誤認 —distance: 5またはdelay: 100, tolerance: 5<ul>のデフォルト style:padding-left: 40pxを解除しないとアイテムの transform が見た目とずれる- server-render + hydration 不一致:Astro / Next.js で SSR すると
transformが server / client で違う。基本client:loadかclient:idleで hydrate crypto.randomUUID()を id にすると key 安定性が崩れる — items が再作成される度 id が変わる。ローカル state で固定値を持つ- drop 後の state 反映:
onDragEndでarrayMoveを呼ぶ。onDragOverを使うと UI 中に挿入される(用途次第)
評価
| 観点 | 評価 | コメント |
|---|---|---|
| アクセシビリティ | ◎ | キーボード / スクリーンリーダー対応 |
| 学習コスト | △ | 概念(sensor / strategy / collision)を知る必要 |
| 拡張性 | ◎ | カスタムセンサー / 衝突判定 / オーバーレイ |
| バンドル | ○ | core + sortable で 30KB 前後 |
| TypeScript | ◎ | 型推論強い |
向く / 向かないケース
- 向く: ソート可能リスト、カンバン、グリッド配置、ファイル並び替え UI
- 向かない: 単純な「追加・削除のみ」のリスト(drag 不要)
- 向かない: 数千要素の同時 drag(
virtualizationと組み合わせると複雑、用途を絞る)
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
dnd-kit v6 で kanban(列間 move + 列内 sort)
dnd-kit で Trello/Jira 風のカンバンを組む実装パターン — 複数 SortableContext を束ね、onDragOver で列間 move、onDragEnd で列内 sort、DragOverlay で表現を分離する設計を触れる demo で確認。
検証日: 2026-05-10
使用バージョン:
@dnd-kit/core/@dnd-kit/sortable@10.0.0/@dnd-kit/utilities対象: シンプル sortable list は組めた、列を跨いだ drag が必要になった人
Trello / Jira 風の カンバン(列内 sort + 列間 move)を dnd-kit で組むパターン。複数 SortableContext を束ねる設計、onDragOver で列間 move、onDragEnd で列内 sort、DragOverlay で表現を分離する、を動く demo で確認します。
触って試す
カードを別列にドラッグできる:列間 move は onDragOver、列内 sort は onDragEnd で処理。掴み中のカードは DragOverlay で本体から分離して表示。
全体の責務分担
カンバン実装で一番混乱しやすい「どの event でどの state を変えるか」を 1 枚で。
このルールを守ると「列間移動の途中で並び順がバグる」「同じカードが 2 つ見える」といった典型バグを回避できる。
1. データ構造の選択
カンバンは「列ごとにカード配列」を持つ shape が扱いやすい:
type CardItem = { id: string; title: string };
type Board = Record<string, CardItem[]>;
const board: Board = {
todo: [{ id: "t1", title: "..." }, { id: "t2", title: "..." }],
doing: [{ id: "d1", title: "..." }],
done: [{ id: "n1", title: "..." }],
};
ID は column も card も同じ namespace で衝突しない ように設計(t1, d1 のように prefix or UUID)。
2. 各列を SortableContext で囲む
列内 sort は、列ごとに独立した SortableContext を作る:
function Column({ id, cards }: { id: string; cards: CardItem[] }) {
const { setNodeRef } = useSortable({ id, data: { type: "column" } });
return (
<div ref={setNodeRef}>
<SortableContext items={cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{cards.map((c) => <Card key={c.id} id={c.id} title={c.title} />)}
</SortableContext>
</div>
);
}
ポイント:
useSortable({ id })は列自体にも付ける:空の列に drop した時、over.idがその列 ID になるdata: { type: "column" }で列とカードを区別しやすくするSortableContextのitemsは その列の card ID 配列だけ を渡す
3. ヘルパー: ID から所属列を見つける
drag 操作のロジックで頻出:
function findContainer(board: Board, id: string): string | null {
if (id in board) return id; // 列自体の ID
for (const col of Object.keys(board)) {
if (board[col].some((c) => c.id === id)) return col;
}
return null;
}
active.id / over.id がどの列に属しているかを board から逆引き。
4. onDragOver で「列間 move」
ドラッグ中(まだドロップしていない)に列間を跨いだら、即座に board の状態を変える:
function handleDragOver(e: DragOverEvent) {
const { active, over } = e;
if (!over) return;
const activeContainer = findContainer(board, String(active.id));
const overContainer = findContainer(board, String(over.id));
if (!activeContainer || !overContainer) return;
if (activeContainer === overContainer) return; // 同列内は何もしない
setBoard((prev) => {
const activeItems = [...prev[activeContainer]];
const overItems = [...prev[overContainer]];
const activeIdx = activeItems.findIndex((c) => c.id === active.id);
const card = activeItems[activeIdx];
activeItems.splice(activeIdx, 1);
// 列に直接 drop なら末尾、card に drop ならその位置
const overIdx =
over.id === overContainer ? overItems.length : overItems.findIndex((c) => c.id === over.id);
overItems.splice(overIdx, 0, card);
return { ...prev, [activeContainer]: activeItems, [overContainer]: overItems };
});
}
ポイント:
- drop 前に board を書き換える → drop 先プレビューを “本物” にして UX が滑らか
- 同列内は何もしない(列内 sort は dragEnd で処理)
- 空列対応:
over.id === overContainerのケースをoverItems.lengthで扱う(末尾 push)
5. onDragEnd で「列内 sort」
ドロップ時、もう動いた後の列内で並べ替え:
function handleDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over) return;
const container = findContainer(board, String(active.id));
if (!container) return;
if (active.id === over.id) return;
const overContainer = findContainer(board, String(over.id));
if (container !== overContainer) return; // 列間は dragOver で処理済
setBoard((prev) => {
const items = [...prev[container]];
const oldIndex = items.findIndex((c) => c.id === active.id);
const newIndex = items.findIndex((c) => c.id === over.id);
return { ...prev, [container]: arrayMove(items, oldIndex, newIndex) };
});
}
onDragOver で列間 move を済ませているので、ここは 同列内の最終位置決定 だけが責務。
6. DragOverlay で「掴み中」を分離表示
<DragOverlay> に「いま掴んでいる card」だけ別 component で描画、元の位置の card は半透明で残す:
import { DragOverlay } from "@dnd-kit/core";
const [activeId, setActiveId] = useState<string | null>(null);
<DndContext
onDragStart={(e) => setActiveId(String(e.active.id))}
onDragEnd={(e) => { setActiveId(null); /* sort */ }}
onDragOver={...}
>
{/* 列・カード描画 */}
<DragOverlay>
{activeId && (() => {
const c = findCard(activeId);
return c ? <Card id={c.id} title={c.title} dragging /> : null;
})()}
</DragOverlay>
</DndContext>
ポイント:
useSortableのtransformは元の位置に対する差分:列間を跨ぐと数学的にずれる- DragOverlay は cursor 直下に絶対配置で見た目が安定
- 元のカードは半透明(
opacity: isDragging ? 0.4 : 1)で「ここから動いた」を示す
7. collisionDetection の選び方
| 関数 | 用途 |
|---|---|
closestCenter | デフォルト、シンプル list 向け |
closestCorners | カンバン推奨。列の境界が近い時、より直感的に検出 |
pointerWithin | カーソル直下を厳密に |
rectIntersection | 矩形の重なりベース、カードが大きい時 |
カンバンは closestCorners で「列の角に近い方」を検出するのが感覚的。
つまずいたポイント
- 同じカードを何度も間で行き来して board が破壊:
onDragOverの中で「列間 move のみ」にする(同列はスキップ) - 空列に drop できない:列の
useSortable({ id: 列ID })を必ずセット +over.id === 列IDを空 push として扱う - DragOverlay 内の card が小さく見える:Overlay 内では
Cardのレイアウトコンテキスト(width 等)が変わる、明示 width 指定 or 元のセルをgetBoundingClientRect()で測る - arrayMove が同列で
oldIndex === newIndexを返す:if (oldIndex === newIndex) return prevで早期 return すると state 更新が不要になり re-render 削減 onDragOverが頻繁に発火:重い state 更新は throttle、または React 18+ のuseTransitionで低優先
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…