List 3 — リスト 3 — 並べ替え

データ表示 中級

このコンポーネントについて

リストアイテムをマウスで掴んで並べ替える操作は、タスク管理ツールや設定画面など多くのアプリで使われるUIパターンです。 外部ライブラリを使えば簡単に実装できますが、依存を増やしたくない場合はHTML5標準のDrag & Drop APIだけで同等の操作感を実現できます。

このページでは、draggable 属性と dragstart / dragover / drop イベントを使ったシンプルな並べ替えリストを紹介します。 ドラッグ中の視覚フィードバック(透明度変化とドロップ位置インジケーター)により、操作中のアイテムの位置が直感的に分かります。

  • ドラッグ&ドロップ並べ替え — HTML5標準の Drag & Drop API のみで実装。外部ライブラリ不要
  • ドラッグハンドル — アイテム左端のグリップアイコン(⠿)をドラッグ開始の起点にする
  • ドラッグ中フィードバック — ドラッグ中のアイテムは半透明に変化し、ドロップ位置に青いインジケーターを表示
  • 順序確認 — 「順序を確認」ボタンで現在の並び順をモーダルで表示する
  • リセット — リセットボタンで元の順序に戻す

実装のポイント・注意点

dragover イベントで preventDefault() を呼ぶのが必須です。 これを忘れるとブラウザがドロップを禁止するため、drop イベントが一切発火しません。

ドラッグ中アイテムを半透明にするとき、dragstart の中で直接クラスを付けると ブラウザのドラッグ画像(ghost image)にも半透明が適用されてしまいます。 setTimeout(..., 0) で1フレーム遅らせることで、ドラッグ画像の生成後にクラスを適用できます。

user-select: none をリストアイテムに設定しています。 これがないとドラッグ中に文字が選択状態になり、意図しない見た目になります。 タッチデバイスではHTML5 Drag & Drop APIが動作しないため、スマホ対応が必要な場合はSortableJSなどのライブラリを検討してください。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

    サンプルソース

    4つのファイルを同じフォルダに保存し、簡易サーバーで index.html を開くと動作確認できます。
    ファイル構成:index.html / style.css / script.js / data/data.jsondata フォルダを作って中に配置)
    保存時の文字コードは UTF-8 を指定してください。fetch()file:// では動作しないため、VS Code の Live Server 等で開いてください。

    <!DOCTYPE html>
    <html lang="ja">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>リスト(ドラッグ&ドロップ並べ替え)サンプル</title>
      <link rel="stylesheet" href="./style.css">
    </head>
    <body>
    
    <ul class="lst" id="js-list"></ul>
    <div class="controls">
      <button id="js-order" type="button">順序を確認</button>
    </div>
    
    <script src="./script.js"></script>
    </body>
    </html>
    /* リスト(ドラッグ&ドロップ並べ替え)— style.css */
    :root {
      --av1: #2B7FE8;
      --av2: #10B981;
      --av3: #F59E0B;
      --av4: #8B5CF6;
      --av5: #EF4444;
    }
    
    *, *::before, *::after { box-sizing: border-box; }
    
    body {
      font-family: sans-serif;
      padding: 24px;
      background: #f8f9fa;
    }
    
    /* リスト本体 */
    .lst {
      list-style: none;
      margin: 0;
      padding: 0;
      background: #fff;
      border-radius: 8px;
      box-shadow: 0 1px 6px rgba(0, 0, 0, 0.10);
      overflow: hidden;
      max-width: 560px;
    }
    
    /* リストアイテム */
    .lst-item {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px 16px;
      border-bottom: 1px solid #E5E9F2;
      cursor: grab;
      user-select: none;
      transition: background 0.1s;
    }
    
    .lst-item:last-child { border-bottom: none; }
    .lst-item:hover { background: #F4F7FF; }
    .lst-item:active { cursor: grabbing; }
    
    /* ドラッグ中:半透明にする */
    .lst-item.is-dragging { opacity: 0.4; }
    
    /* ドロップ位置インジケーター(青いボーダー) */
    .lst-item.is-over { border-top: 2px solid #2B7FE8; }
    
    /* ドラッグハンドル */
    .lst-handle {
      flex-shrink: 0;
      color: #CBD5E1;
      font-size: 18px;
      cursor: grab;
      padding: 0 4px;
      line-height: 1;
    }
    
    /* アバター */
    .lst-avatar {
      flex-shrink: 0;
      width: 40px;
      height: 40px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      font-weight: 700;
      color: #fff;
    }
    
    .lst-avatar[data-color="1"] { background: var(--av1); }
    .lst-avatar[data-color="2"] { background: var(--av2); }
    .lst-avatar[data-color="3"] { background: var(--av3); }
    .lst-avatar[data-color="4"] { background: var(--av4); }
    .lst-avatar[data-color="5"] { background: var(--av5); }
    
    /* 本文エリア */
    .lst-body { flex: 1; min-width: 0; }
    
    .lst-name {
      font-size: 14px;
      font-weight: 700;
      color: #1A2332;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    
    .lst-meta {
      font-size: 12px;
      color: #6B7280;
      margin-top: 2px;
    }
    
    /* 確認ボタン */
    .controls {
      max-width: 560px;
      margin-top: 12px;
      display: flex;
      justify-content: flex-end;
    }
    
    .controls button {
      padding: 6px 16px;
      font-size: 13px;
      font-family: sans-serif;
      color: #2B7FE8;
      background: #fff;
      border: 1.5px solid #2B7FE8;
      border-radius: 6px;
      cursor: pointer;
      transition: background 0.15s, color 0.15s;
    }
    
    .controls button:hover {
      background: #2B7FE8;
      color: #fff;
    }
    
    @media (max-width: 480px) {
      body { padding: 12px; }
      .lst, .controls { max-width: 100%; }
    }
    var AVATAR_COLORS = ['1', '2', '3', '4', '5'];
    var dragItem = null;
    
    // JSONを読み込んでリストを描画する
    fetch('./data/data.json')
      .then(function (res) {
        if (!res.ok) { throw new Error('HTTP ' + res.status); }
        return res.json();
      })
      .then(function (data) { renderList(data.rows); })
      .catch(function (err) {
        console.error('データ読み込みエラー:', err);
        var ul = document.getElementById('js-list');
        var msg = document.createElement('p');
        msg.style.cssText = 'color:#9B1C1C; font-size:14px;';
        msg.textContent = 'データを読み込めませんでした。';
        ul.parentNode.appendChild(msg);
      });
    
    // リストを描画する
    function renderList(rows) {
      var ul = document.getElementById('js-list');
      ul.innerHTML = '';
    
      rows.forEach(function (row, index) {
        var li = document.createElement('li');
        li.className = 'lst-item';
        li.setAttribute('draggable', 'true');
        li.setAttribute('data-id', String(row.id));
    
        // ドラッグハンドル
        var handle = document.createElement('span');
        handle.className = 'lst-handle';
        handle.setAttribute('aria-hidden', 'true');
        handle.textContent = '⠿';
    
        // アバター
        var avatar = document.createElement('div');
        avatar.className = 'lst-avatar';
        avatar.setAttribute('data-color', AVATAR_COLORS[index % AVATAR_COLORS.length]);
        avatar.textContent = row.name ? row.name.charAt(0) : '?';
    
        // 本文エリア
        var body = document.createElement('div');
        body.className = 'lst-body';
    
        var nameEl = document.createElement('div');
        nameEl.className = 'lst-name';
        nameEl.textContent = row.name || '';
    
        var meta = document.createElement('div');
        meta.className = 'lst-meta';
        meta.textContent = row.role || '';
    
        body.appendChild(nameEl);
        body.appendChild(meta);
    
        li.appendChild(handle);
        li.appendChild(avatar);
        li.appendChild(body);
        ul.appendChild(li);
      });
    
      attachDragEvents(ul);
    }
    
    // ドラッグ&ドロップイベントを登録する
    function attachDragEvents(ul) {
      ul.addEventListener('dragstart', function (e) {
        dragItem = e.target.closest('.lst-item');
        if (!dragItem) { return; }
        // ブラウザのドラッグ画像生成後にクラスを付ける
        setTimeout(function () { dragItem.classList.add('is-dragging'); }, 0);
      });
    
      ul.addEventListener('dragover', function (e) {
        e.preventDefault(); // ドロップを許可する(必須)
        var target = e.target.closest('.lst-item');
        if (!target || target === dragItem) { return; }
        clearOverClass(ul);
        target.classList.add('is-over');
      });
    
      ul.addEventListener('dragleave', function (e) {
        if (!ul.contains(e.relatedTarget)) { clearOverClass(ul); }
      });
    
      ul.addEventListener('drop', function (e) {
        e.preventDefault();
        var target = e.target.closest('.lst-item');
        if (!target || target === dragItem) {
          clearOverClass(ul);
          return;
        }
        // ドラッグアイテムをドロップ先の直前に挿入する
        ul.insertBefore(dragItem, target);
        clearOverClass(ul);
      });
    
      ul.addEventListener('dragend', function () {
        if (dragItem) { dragItem.classList.remove('is-dragging'); }
        clearOverClass(ul);
        dragItem = null;
      });
    }
    
    // is-over クラスを全アイテムから除去する
    function clearOverClass(ul) {
      ul.querySelectorAll('.lst-item.is-over').forEach(function (el) {
        el.classList.remove('is-over');
      });
    }
    
    // 現在の並び順をアラートで確認する
    document.getElementById('js-order').addEventListener('click', function () {
      var items = document.querySelectorAll('#js-list .lst-item');
      var result = [];
      items.forEach(function (li, i) {
        var nameEl = li.querySelector('.lst-name');
        result.push((i + 1) + '. ' + (nameEl ? nameEl.textContent : ''));
      });
      alert('現在の並び順:\n' + result.join('\n'));
    });
    
    {
      "rows": [
        { "id": 1, "name": "田中 一郎", "role": "リードエンジニア" },
        { "id": 2, "name": "鈴木 花子", "role": "UIデザイナー" },
        { "id": 3, "name": "佐藤 次郎", "role": "バックエンドエンジニア" },
        { "id": 4, "name": "山田 三枝", "role": "セールスマネージャー" },
        { "id": 5, "name": "伊藤 健太", "role": "エンジニア" },
        { "id": 6, "name": "渡辺 美咲", "role": "コンテンツディレクター" },
        { "id": 7, "name": "中村 剛",   "role": "インフラエンジニア" },
        { "id": 8, "name": "小林 奈々", "role": "HRマネージャー" }
      ]
    }

    AI用プロンプト

    このプロンプトをChatGPTやClaudeに渡すと、同様のドラッグ&ドロップリストをゼロから生成・カスタマイズできます。

    ※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

    💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。スマホ対応が必要な場合は「タッチデバイスにも対応してください」と追記するとライブラリ使用を提案してもらえます。

    # リスト(ドラッグ&ドロップ並べ替え)作成依頼
    
    ## 概要
    HTML5のDrag & Drop APIを使ってリストアイテムを並べ替えられるUIを実装してください。
    外部ライブラリは使用せず、バニラJSのみで実装します。
    
    ## 要件
    - 各リストアイテムにドラッグハンドル(グリップアイコン ⠿)を設ける
    - ドラッグ中はアイテムを半透明にする(opacity: 0.4)
    - ドロップ先にラインインジケーター(青いボーダー)を表示してドロップ位置を示す
    - ドロップ後はDOMを更新してアイテムを移動する
    - 「順序を確認」ボタンで現在の並び順を名前のリストとして表示する
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript で実装
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    
    ## 動作詳細
    各 li 要素に draggable="true" と data-id 属性を設定する。
    dragstart でドラッグ中の要素を変数に記録し、setTimeout(..., 0) で is-dragging クラスを付与する。
    dragover で preventDefault() を呼んでドロップを許可し、is-over クラスでインジケーターを表示する。
    dragleave で is-over クラスを除去する。
    drop で insertBefore を使い、ドラッグ要素をドロップ位置に移動する。
    dragend で is-dragging / is-over を全アイテムから除去する。
    動的データのDOM挿入は textContent を使い innerHTML に変数を直接渡さない。
    
    ## 出力形式
    HTML・CSS・JavaScriptを分けて出力してください。
    各ファイルは単独でコピー&ペーストして使えるよう記述してください。