Reactで、列の間でカードを動かせるカンバンボードを作る方法

​​React​dnd-kit,TypeScript

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 をキーにして、カードの配列を持つオブジェクトです。カードの並び順は、配列の順です。

ts
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 からどの列かを探す関数と、カードを別の列へ移す関数を、同じファイルに用意しました。

ts
// 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つです。

text
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

カード1枚は、useSortable で作ります。カード全体をつかんで動かせるようにしました。

tsx
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 を渡しました。

tsx
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 に、ドラッグの始まり・途中・終わりの処理を渡します。

tsx
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回ドラッグしました。状態は、列ごとのカードを上から順に書いています。

text
最初: 未着手: 見積もりを作る, 画像を差し替える, 議事録をまとめる | 作業中: ログイン画面を直す | 完了: (なし)
1. 「見積もりを作る」を作業中の列の下のほうへ
   → 作業中: ログイン画面を直す, 見積もりを作る
2. 「画像を差し替える」を空の完了の列へ
   → 完了: 画像を差し替える
3. 作業中の列で「ログイン画面を直す」を1つ下へ
   → 作業中: 見積もりを作る, ログイン画面を直す
最後: 未着手: 議事録をまとめる | 作業中: 見積もりを作る, ログイン画面を直す | 完了: 画像を差し替える

列の間の移動も、カードが1枚もない列への移動も、同じ列の中での並べ替えも、思ったとおりに動きました。1回目と2回目の様子を、GIF にしました。

マウスで、「見積もりを作る」を作業中の列へ、「画像を差し替える」を空の完了の列へドラッグする様子。下の枠に、何回目のドラッグか、マウスの状態、列ごとのカードの枚数が出ている
マウスで2回ドラッグして、カードを別の列へ動かしたときの動き

下の枠は、撮影用のスクリプトが書き足した表示です。マウスのボタンを押している間は、カーソルの輪をピンクにしました。押した瞬間と離した瞬間は、マウスの状態の文字もピンクにしています。列のカードの枚数が変わった瞬間は、その列の枚数をピンクにしています。

2回目のドラッグでは、完了の列へ向かう途中で、作業中の列の枚数が一度3枚に増えています。カードが通り道の作業中の列の上を通ったときにも onDragOver で移され、いったんその列に入りました。完了の列の上へ進むと、さらに完了の列へ移りました。離したときには、カードは完了の列に入っていました。

離したときにだけ移すと、移る先にすき間ができない

onDragOver を使わず、onDragEnd の中で列の間を移す形にしても、離した後の結果は上と同じになりました。違ったのは、ドラッグの途中です。1回目と同じドラッグで、離す直前の状態を比べました。比べるために書き換えた onDragEnd では、列が違うときに、次の1行で移しています(from・to は findColumn で求めた列です)。

tsx
if (from !== to) return moveToColumn(prev, activeId, from, to, overId);

離す直前の状態は次のとおりです。

text
onDragOver あり:  未着手: 画像を差し替える, 議事録をまとめる | 作業中: ログイン画面を直す, 見積もりを作る
onDragOver なし:  未着手: 見積もりを作る, 画像を差し替える, 議事録をまとめる | 作業中: ログイン画面を直す

onDragOver がないと、離すまでカードは元の列の配列にあります。このとき作業中の列の「ログイン画面を直す」の transform は none で、作業中の列には何の変化もありませんでした。onDragOver があると、後の DragOverlay の節で書くように、薄くしたカードが、離す前から移る先の位置に表示されます。

空の列に落とすには、列を useDroppable にする

列の useDroppable を disabled: true で無効にして、同じ3回のドラッグをしました。2回目に、空の完了の列へ向けて離した「画像を差し替える」は、完了ではなく作業中の列の先頭に入りました。

text
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) でした。

text
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 キーで「見積もりを作る」にフォーカスし、スペースキー、→キー、スペースキーの順に押すと、カードが作業中の列へ移りました。

text
後: 未着手: 画像を差し替える, 議事録をまとめる | 作業中: 見積もりを作る, ログイン画面を直す | 完了: (なし)

続けて Tab キーで「ログイン画面を直す」にフォーカスし、スペースキー、→キーを押すと、空の完了の列へ移りました。もう一度→キーを押しても、右端の完了の列のままでした。最後にスペースキーを押すと、完了の列で確定しました。

TypeScript で dnd-kit の型定義がエラーになるとき

検証の tsconfig.json には "skipLibCheck": true を入れました。外して tsc を実行すると、dnd-kit の型定義ファイルで次のエラーが7か所出ます。

text
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 を入れると型チェックが通りました。

​Posted by 86