Reactで、列の間でカードを動かせるカンバンボードを作る方法
dnd-kit で列ごとに SortableContext を置き、ドラッグ中に呼ばれる onDragOver でカードを別の列の配列へ移すと、列の間でカードを動かせるカンバンボードを作れます。タスク管理で、カードを「未着手」「作業中」「完了」の列の間でドラッグ&ドロップして、状態を変える画面に使えます。
検証環境では、3つの列を持つボードを作り、マウスとキーボードでカードを別の列へ動かせることを確かめました。カードが1枚もない列に落とせなくなる場合と、その直し方もあわせて書きます。
検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは react / react-dom 19.3.1、@dnd-kit/core 6.3.1、@dnd-kit/sortable 10.0.0、@dnd-kit/utilities 3.2.2、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Chromium 153(ヘッドレス、Playwright 1.63.0 で操作)で動かしました。StrictMode では包んでいません。
スマホなどのタッチ操作、列そのものの並べ替え、ボードの内容の保存、スクリーンリーダーでの読み上げは、この記事では試していません。
ボードのデータは、列ごとのカードの配列で持つ
列は「未着手」「作業中」「完了」の3つにしました。ボードの状態は、列の id をキーにして、カードの配列を持つオブジェクトです。カードの並び順は、配列の順です。
export type ColumnId = 'todo' | 'doing' | 'done';
export type Card = { id: string; title: string };
export type Board = Record<ColumnId, Card[]>;
export const columnTitles: Record<ColumnId, string> = {
todo: '未着手',
doing: '作業中',
done: '完了',
};
export const columnIds = Object.keys(columnTitles) as ColumnId[];
export const initialBoard: Board = {
todo: [
{ id: 'c1', title: '見積もりを作る' },
{ id: 'c2', title: '画像を差し替える' },
{ id: 'c3', title: '議事録をまとめる' },
],
doing: [{ id: 'c4', title: 'ログイン画面を直す' }],
done: [],
};
ドラッグのイベントでは、つかんだカードの id(active.id)と、その下にあるもの(カードか列)の id(over.id)を使います。そこで、id からどの列かを探す関数と、カードを別の列へ移す関数を、同じファイルに用意しました。
// id がどの列のものかを探す(列の id ならその列、カードの id ならカードがある列)
export function findColumn(board: Board, id: string): ColumnId | undefined {
if (id in board) return id as ColumnId;
return columnIds.find((col) => board[col].some((card) => card.id === id));
}
// カードを別の列へ移す(overId がカードならその位置へ、列ならその列の末尾へ)
export function moveToColumn(board: Board, activeId: string, from: ColumnId, to: ColumnId, overId: string): Board {
const card = board[from].find((c) => c.id === activeId)!;
const overIndex = board[to].findIndex((c) => c.id === overId);
const index = overIndex === -1 ? board[to].length : overIndex;
return {
...board,
[from]: board[from].filter((c) => c.id !== activeId),
[to]: [...board[to].slice(0, index), card, ...board[to].slice(index)],
};
}
カードと列のコンポーネント
入れるパッケージは次の3つです。
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
カード1枚は、useSortable で作ります。カード全体をつかんで動かせるようにしました。
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Card } from './board';
export function SortableCard({ card }: { card: Card }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: card.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
// 動かしている間、元の場所には薄い影だけを残す
opacity: isDragging ? 0.3 : 1,
};
return (
<li ref={setNodeRef} style={style} className="card" data-id={card.id} {...attributes} {...listeners}>
{card.title}
</li>
);
}
列のコンポーネントでは、カードの一覧を列ごとの SortableContext で包みます。@dnd-kit/sortable の README によると、useSortable は useDraggable と useDroppable を組み合わせたものです。SortableContext には、文字列の id を渡せます(型定義に id?: string があります)。ここでは列の id を渡しました。
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { columnTitles, type Card, type ColumnId } from './board';
import { SortableCard } from './SortableCard';
export function Column({ id, cards }: { id: ColumnId; cards: Card[] }) {
// 列そのものも、カードを落とせる場所にする(カードが1枚もない列に落とすため)
const { setNodeRef } = useDroppable({ id });
return (
<section className="column" data-column={id}>
<h2>
{columnTitles[id]}({cards.length})
</h2>
<SortableContext id={id} items={cards} strategy={verticalListSortingStrategy}>
<ul ref={setNodeRef} className="cards">
{cards.map((card) => (
<SortableCard key={card.id} card={card} />
))}
</ul>
</SortableContext>
</section>
);
}
useDroppable で、カードを並べる ul を、カードを落とせる場所にしています。これがないと、カードが1枚もない列に落とせませんでした。結果は後の節で書きます。ul には CSS で min-height: 120px を付け、空の列にも高さを持たせました。
onDragOver で、ドラッグ中にカードを別の列へ移す
ボード全体のコンポーネントです。DndContext に、ドラッグの始まり・途中・終わりの処理を渡します。
import { useState } from 'react';
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { columnIds, findColumn, initialBoard, moveToColumn, type Board, type Card } from './board';
import { Column } from './Column';
export function KanbanBoard() {
const [board, setBoard] = useState<Board>(initialBoard);
const [activeCard, setActiveCard] = useState<Card | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// つかんだカードを覚えておく(DragOverlay に描くため)
function handleDragStart({ active }: DragStartEvent) {
const col = findColumn(board, String(active.id));
setActiveCard(col ? board[col].find((c) => c.id === active.id) ?? null : null);
}
// ドラッグ中に別の列へ入ったら、その場でカードを移す
function handleDragOver({ active, over }: DragOverEvent) {
if (!over) return;
const activeId = String(active.id);
const overId = String(over.id);
setBoard((prev) => {
const from = findColumn(prev, activeId);
const to = findColumn(prev, overId);
if (!from || !to || from === to) return prev;
return moveToColumn(prev, activeId, from, to, overId);
});
}
// 離したら、同じ列の中での並べ替えを確定する
function handleDragEnd({ active, over }: DragEndEvent) {
setActiveCard(null);
if (!over) return;
const activeId = String(active.id);
const overId = String(over.id);
setBoard((prev) => {
const col = findColumn(prev, activeId);
if (!col || col !== findColumn(prev, overId)) return prev;
const oldIndex = prev[col].findIndex((c) => c.id === activeId);
const newIndex = prev[col].findIndex((c) => c.id === overId);
if (newIndex === -1 || oldIndex === newIndex) return prev;
return { ...prev, [col]: arrayMove(prev[col], oldIndex, newIndex) };
});
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveCard(null)}
>
<div className="board">
{columnIds.map((id) => (
<Column key={id} id={id} cards={board[id]} />
))}
</div>
<DragOverlay>
{activeCard ? <div className="card overlay">{activeCard.title}</div> : null}
</DragOverlay>
</DndContext>
);
}
役割を分けているのは、handleDragOver と handleDragEnd です。別の列へ移すのは、ドラッグ中に呼ばれる handleDragOver です。カードが別の列のカードや列の上に来たら、その時点で state の配列を書き換えます。離す前から、カードは移る先の列の中に並びます。handleDragEnd は、離したときに、同じ列の中での並べ替えを arrayMove で確定するだけです。
当たり判定(collisionDetection)には closestCorners を使いました。型定義のコメントでは、ある四角形の四隅に近い四角形を、四角形の一覧から返す処理と説明されています。PointerSensor の activationConstraint に渡した distance: 5 は、押してから動かした距離が 5px を超えたところでドラッグを始める設定です(npm で配布されている @dnd-kit/core 6.3.1 のコードで確かめました)。
動かした結果
Chromium で、マウスを使って3回ドラッグしました。状態は、列ごとのカードを上から順に書いています。
最初: 未着手: 見積もりを作る, 画像を差し替える, 議事録をまとめる | 作業中: ログイン画面を直す | 完了: (なし)
1. 「見積もりを作る」を作業中の列の下のほうへ
→ 作業中: ログイン画面を直す, 見積もりを作る
2. 「画像を差し替える」を空の完了の列へ
→ 完了: 画像を差し替える
3. 作業中の列で「ログイン画面を直す」を1つ下へ
→ 作業中: 見積もりを作る, ログイン画面を直す
最後: 未着手: 議事録をまとめる | 作業中: 見積もりを作る, ログイン画面を直す | 完了: 画像を差し替える
列の間の移動も、カードが1枚もない列への移動も、同じ列の中での並べ替えも、思ったとおりに動きました。1回目と2回目の様子を、GIF にしました。

下の枠は、撮影用のスクリプトが書き足した表示です。マウスのボタンを押している間は、カーソルの輪をピンクにしました。押した瞬間と離した瞬間は、マウスの状態の文字もピンクにしています。列のカードの枚数が変わった瞬間は、その列の枚数をピンクにしています。
2回目のドラッグでは、完了の列へ向かう途中で、作業中の列の枚数が一度3枚に増えています。カードが通り道の作業中の列の上を通ったときにも onDragOver で移され、いったんその列に入りました。完了の列の上へ進むと、さらに完了の列へ移りました。離したときには、カードは完了の列に入っていました。
離したときにだけ移すと、移る先にすき間ができない
onDragOver を使わず、onDragEnd の中で列の間を移す形にしても、離した後の結果は上と同じになりました。違ったのは、ドラッグの途中です。1回目と同じドラッグで、離す直前の状態を比べました。比べるために書き換えた onDragEnd では、列が違うときに、次の1行で移しています(from・to は findColumn で求めた列です)。
if (from !== to) return moveToColumn(prev, activeId, from, to, overId);
離す直前の状態は次のとおりです。
onDragOver あり: 未着手: 画像を差し替える, 議事録をまとめる | 作業中: ログイン画面を直す, 見積もりを作る
onDragOver なし: 未着手: 見積もりを作る, 画像を差し替える, 議事録をまとめる | 作業中: ログイン画面を直す
onDragOver がないと、離すまでカードは元の列の配列にあります。このとき作業中の列の「ログイン画面を直す」の transform は none で、作業中の列には何の変化もありませんでした。onDragOver があると、後の DragOverlay の節で書くように、薄くしたカードが、離す前から移る先の位置に表示されます。
空の列に落とすには、列を useDroppable にする
列の useDroppable を disabled: true で無効にして、同じ3回のドラッグをしました。2回目に、空の完了の列へ向けて離した「画像を差し替える」は、完了ではなく作業中の列の先頭に入りました。
2. 「画像を差し替える」を空の完了の列へ
→ 作業中: 画像を差し替える, ログイン画面を直す, 見積もりを作る | 完了: (なし)
useSortable は中で useDroppable を使っているので、カードの1枚1枚は、カードを落とせる場所になります。一方、空の列にはカードがないので、列の useDroppable がなければ、列の中に落とせる場所が1つもありません。列の ul を useDroppable にした完成版では、同じドラッグでカードが完了の列に入りました。このとき findColumn が完了の列を返すのは、over.id が列の id('done')のときだけです。moveToColumn は、over.id が列の id のとき、カードを列の末尾に入れます。
カードが1枚もない列にも落とせるように、列の要素を useDroppable にして、列の id を over.id で受け取れるようにします。
DragOverlay で、つかんだカードを列の外に描く
DragOverlay の中には、つかんだカードと同じ見た目の要素を描いています。dnd-kit の README では、DragOverlay は、ふつうの文書の流れから外れて、ビューポート(ブラウザの表示領域)に対する位置で描かれる要素だと説明されています。
1回目のドラッグの途中で、「見積もりを作る」の要素を調べました。マウスの位置は (380, 167) でした。
DragOverlay あり:
作業中の列の中の li 中心 (380, 128) opacity 0.3
列の外の DragOverlay 中心 (380, 167) opacity 1
DragOverlay なし:
作業中の列の中の li 中心 (380, 167) opacity 0.3
DragOverlay があると、マウスに付いてくるのは、くっきり描かれた別の要素です。列の中の元のカードは薄く表示されたまま、離したときに入る位置に並びます。DragOverlay がないと、マウスに付いてくるのは薄くした元のカードそのものでした。どこに入るかを薄いカードで見せたいときは、DragOverlay を使うと分かりやすくなります。
キーボードでも、列の間を動かせる
KeyboardSensor に sortableKeyboardCoordinates を渡してあるので、キーボードでも動かせます。Tab キーで「見積もりを作る」にフォーカスし、スペースキー、→キー、スペースキーの順に押すと、カードが作業中の列へ移りました。
後: 未着手: 画像を差し替える, 議事録をまとめる | 作業中: 見積もりを作る, ログイン画面を直す | 完了: (なし)
続けて Tab キーで「ログイン画面を直す」にフォーカスし、スペースキー、→キーを押すと、空の完了の列へ移りました。もう一度→キーを押しても、右端の完了の列のままでした。最後にスペースキーを押すと、完了の列で確定しました。
TypeScript で dnd-kit の型定義がエラーになるとき
検証の tsconfig.json には "skipLibCheck": true を入れました。外して tsc を実行すると、dnd-kit の型定義ファイルで次のエラーが7か所出ます。
node_modules/@dnd-kit/core/dist/components/DragOverlay/DragOverlay.d.ts(8,28): error TS2503: Cannot find namespace 'JSX'.
dnd-kit の型定義は、グローバルの JSX という名前空間を使っています。一方、@types/react 19.3.0 の index.d.ts では、JSX は React の名前空間の中にあり、グローバルには宣言されていませんでした。自分で書いた .tsx にはエラーはなく、skipLibCheck を入れると型チェックが通りました。





