ジャンルから探す

Web Design English

Swapyでカンバンボードやタスク管理に実装できるUI実装【JavaScript】

Swapy は、既存のレイアウトに data-swapy-slotdata-swapy-item の2つの属性を足すだけで、ドラッグで入れ替えられるUIに変えられるライブラリです。フレームワークに依存しません。

先に重要な性質を書いておきます。Swapy が行うのは「スワップ(交換)」であって、リストへの挿入や詰め直しではありません。 スロットの位置は固定で、その中身だけが入れ替わります。ここを理解しないと期待と挙動がずれます。

この記事は2024年の公開後、2026年9月に全面的に書き直しました。 公開当時のコードはSwapyを使わず、素のHTML5ドラッグ&ドロップAPIで書かれていたため、タイトルと内容が一致していませんでした。あわせて、そのコードにあったFirefoxで動作しない問題も後半で解説します。

スポンサーリンク

Swapy とは

ドラッグでの入れ替えUIを、HTMLの構造を変えずに後付けできるのが特徴です。

項目 内容
方式 スワップ(1対1の交換)
依存 なし(フレームワーク非依存)
導入 属性2つ+初期化1行
タッチ対応 あり(Pointer Events ベース)
アニメーション dynamic / spring / none

スワップと並べ替えの違い

ここが最も誤解されやすい点です。

  • スワップ(Swapy):A を C の位置へ動かすと、C が A のいた場所へ来ます。位置の数は変わりません
  • 並べ替え(ソータブル):A を C の位置へ動かすと、B と C が1つずつ前に詰まります

ダッシュボードのウィジェット配置やグリッドレイアウトの入れ替えにはスワップが適しています。一方、「To Do のタスクを In Progress の3番目に挿入する」といったカンバン特有の操作は、スワップでは表現しきれません。用途に応じて選んでください。

導入

CDN から読み込む場合は、UMD ビルドを使うとグローバルに Swapy が生えます。

<script src="https://unpkg.com/swapy@1.0.5/dist/swapy.min.js"></script>
<script>
  const container = document.querySelector('.container');
  const swapy = Swapy.createSwapy(container);
</script>

npm で入れる場合は ES モジュールとして読み込みます。

npm install swapy
import { createSwapy } from 'swapy';

const container = document.querySelector('.container');
const swapy = createSwapy(container);

バージョンは固定してください。 https://unpkg.com/swapy/dist/... のようにバージョンを省略すると、メジャー更新でAPIが変わったときに突然動かなくなります。

スポンサーリンク

基本の実装

スロット(置き場所)とアイテム(中身)を入れ子にするのが基本構造です。

<div class="container">
  <div class="slot" data-swapy-slot="a">
    <div class="card" data-swapy-item="1">Card 1</div>
  </div>

  <div class="slot" data-swapy-slot="b">
    <div class="card" data-swapy-item="2">Card 2</div>
  </div>

  <div class="slot" data-swapy-slot="c">
    <div class="card" data-swapy-item="3">Card 3</div>
  </div>
</div>
.container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.slot {
  min-height: 120px;
}

.card {
  height: 100%;
  display: grid;
  place-items: center;
  background: #f2f2f2;
  border: 1px solid #ccc;
  border-radius: 8px;
  cursor: grab;
  user-select: none;   /* ドラッグ中に文字が選択されるのを防ぐ */
}

.card:active {
  cursor: grabbing;
}
const container = document.querySelector('.container');

const swapy = Swapy.createSwapy(container, {
  animation: 'dynamic',
});

これだけで動きます。3つの約束事を守ってください。

  • スロットの名前は重複させないdata-swapy-slot の値はコンテナ内で一意にします
  • アイテムの名前も一意にするdata-swapy-item の値がデータの識別子になります
  • スロットの直下にアイテムを1つだけ置く:この構造が前提です

オプション

const swapy = Swapy.createSwapy(container, {
  animation: 'dynamic',      // 'dynamic' | 'spring' | 'none'
  swapMode: 'hover',         // 'hover' | 'drop'
  dragAxis: 'both',          // 'x' | 'y' | 'both'
  autoScrollOnDrag: true,    // 端まで運んだら自動スクロール
  dragOnHold: false,         // 長押ししてからドラッグ開始
  enabled: true,
  manualSwap: false,         // 入れ替えを自分で制御する
});
オプション 効果 使いどころ
swapMode: 'hover' 重ねた瞬間に入れ替わる 結果をすぐ見せたい
swapMode: 'drop' 指を離したときに確定する 誤操作を減らしたい
dragAxis: 'y' 縦方向のみ 1列のリスト
dragOnHold: true 長押ししないと動かない スマホでスクロールと競合させない
autoScrollOnDrag: true 画面端で自動スクロール 長いリスト

スマートフォン対応では dragOnHold: true がほぼ必須です。 これが無いと、リストをスクロールしようとした指がカードを掴んでしまいます。

ドラッグできる範囲を限定する

カード全体ではなく「掴む部分」だけでドラッグさせたい場合は data-swapy-handle を使います。

<div class="slot" data-swapy-slot="a">
  <div class="card" data-swapy-item="1">
    <span class="handle" data-swapy-handle>⠿</span>
    <a href="/task/1">タスクの詳細を見る</a>
  </div>
</div>

カード内にリンクやボタンがある場合は、ハンドルを使ってください。 カード全体をドラッグ対象にすると、リンクをクリックしたつもりがドラッグになります。

スポンサーリンク

並び順をサーバーに保存する

onSwapEnd を使います。 hasChanged で「実際に変わったか」が分かるので、無駄なリクエストを避けられます。

const swapy = Swapy.createSwapy(container, { animation: 'dynamic' });

swapy.onSwapEnd(async (event) => {
  // 掴んで戻しただけなら何もしない
  if (!event.hasChanged) return;

  // [{ slot: 'a', item: '3' }, { slot: 'b', item: '1' }, ...]
  const layout = event.slotItemMap.asArray;

  try {
    const res = await fetch('/api/layout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ layout }),
      signal: AbortSignal.timeout(5000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (err) {
    console.error('並び順の保存に失敗しました', err);
    showToast('保存できませんでした');
  }
});

取得できる形式は3種類あります。用途に応じて選んでください。

event.slotItemMap.asArray;   // [{ slot, item }] ← サーバー送信に向く(順序が保たれる)
event.slotItemMap.asObject;  // { a: '3', b: '1' } ← 特定スロットの中身を引きたいとき
event.slotItemMap.asMap;     // Map オブジェクト

現在の状態はいつでも swapy.slotItemMap() で取れます。

イベントの使い分け

イベント 発火のタイミング 用途
onSwapStart ドラッグ開始時 ゴミ箱を表示するなど
onBeforeSwap 入れ替え直前 false を返すと中止できる
onSwap 入れ替えのたび 途中経過の表示
onSwapEnd ドラッグ終了時 保存はここ

保存を onSwap に書かないでください。 swapMode: 'hover' では、カードを運ぶ途中で何度も発火します。1回のドラッグでリクエストが10回飛ぶことになります。

特定の入れ替えを禁止する

swapy.onBeforeSwap((event) => {
  // ロックされたスロットへは移動させない
  const target = container.querySelector(`[data-swapy-slot="${event.toSlot}"]`);
  if (target?.dataset.locked === 'true') {
    return false;   // 入れ替えを中止する
  }
  return true;
});

動的に要素を追加したら update() を呼ぶ

これを忘れると、後から追加したカードがドラッグできません。

function addCard(slotName, itemId, text) {
  const slot = document.createElement('div');
  slot.className = 'slot';
  slot.dataset.swapySlot = slotName;

  const card = document.createElement('div');
  card.className = 'card';
  card.dataset.swapyItem = itemId;
  card.textContent = text;

  slot.appendChild(card);
  container.appendChild(slot);

  swapy.update();   // ← 必須
}

画面から離れるときは destroy() でイベントリスナーを解放します。SPAでは必ず呼んでください。

swapy.destroy();

一時的に無効化したいだけなら enable(false) です。

swapy.enable(false);  // 編集モードを抜けたとき
swapy.enable(true);   // 編集モードに入ったとき

素のHTML5 DnD で書く場合の注意点

ライブラリを入れずに実装する選択肢もあります。ただしネイティブのドラッグ&ドロップAPIには、知らないと必ず踏む落とし穴があります

1. dataTransfer.setData() を呼ばないとFirefoxで動かない

最も多いつまずきです。 Chrome では動くのに Firefox でドラッグが始まらない場合、ほぼこれが原因です。

card.addEventListener('dragstart', (e) => {
  // これが無いと Firefox ではドラッグが開始されない
  e.dataTransfer.setData('text/plain', card.dataset.id);
  e.dataTransfer.effectAllowed = 'move';
});

2. dragstart で display: none にしない

// ドラッグ中の要素を消すと、ドラッグ自体が中断されることがある
card.addEventListener('dragstart', () => {
  setTimeout(() => { card.style.display = 'none'; }, 0);
});

// 半透明にする程度にとどめる
card.addEventListener('dragstart', () => {
  requestAnimationFrame(() => card.classList.add('is-dragging'));
});
.card.is-dragging { opacity: .4; }

3. dragover で必ず preventDefault() する

dragoverpreventDefault() を呼ばないと drop が発火しません。 これは仕様上の決まりで、忘れると「ドロップできない」という症状になります。

4. タッチデバイスでは動かない

HTML5 のドラッグ&ドロップAPIは、モバイルブラウザでは基本的に動作しません。 スマートフォン対応が必要なら、Pointer Events で自作するか、Swapy のようなライブラリを使うことになります。

5. キーボードで操作できない

ドラッグ&ドロップは、そのままではマウスやタッチを使えないユーザーが操作できません。並べ替えが必須の機能なら、「上へ」「下へ」ボタンなどの代替手段を用意してください。

<div class="card" data-swapy-item="1">
  <span data-swapy-handle>⠿</span>
  タスク名
  <button type="button" class="move-up" aria-label="ひとつ上へ移動">↑</button>
  <button type="button" class="move-down" aria-label="ひとつ下へ移動">↓</button>
</div>

状態の変化はスクリーンリーダーにも伝える必要があります。

<p role="status" aria-live="polite" class="visually-hidden"></p>
swapy.onSwapEnd((event) => {
  if (!event.hasChanged) return;
  status.textContent = '並び順を変更しました';
});

ライブラリを選ぶときの判断

やりたいこと 選ぶもの
位置を1対1で入れ替える(ダッシュボード等) Swapy
リストへの挿入と詰め直し(カンバン) ソータブル系のライブラリ
ファイルのドロップ受付だけ ネイティブDnDで十分
自由な位置へのドラッグ移動 Pointer Events で自作

Pointer Events を使った自作の実装はJavaScriptのElementメソッドsetPointerCapture() とあわせて解説しています。

まとめ

  • Swapy は data-swapy-slotdata-swapy-itemcreateSwapy() の1行で導入できる
  • 行うのは「スワップ(交換)」で、リストの詰め直しではない。用途を見極める
  • スマホ対応には dragOnHold: true、誤操作対策には swapMode: 'drop'
  • カード内にリンクがあるなら data-swapy-handle を使う
  • 保存は onSwapEndhasChanged を見る。 onSwap に書くとリクエストが多発する
  • 要素を動的に追加したら update()、画面を離れるときは destroy()
  • 素のDnDでは dataTransfer.setData() を呼ばないとFirefoxで動かない
  • ネイティブDnDはモバイルで動かない。キーボード代替手段も用意する

「並べ替えUI」と一括りにされがちですが、スワップなのか挿入なのかで選ぶ道具が変わります。作りたい操作を先に決めてから、ライブラリを選んでください。