リスト — ライブラリ版並べ替え

データ表示 初級

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

リストアイテムをドラッグ&ドロップで並べ替えるUIを、軽量ライブラリ「SortableJS」を使って実装する例です。 CDNを1本読み込むだけで、ドラッグハンドル指定・スマホでのタッチ操作・並び順の保存までまとめて対応できます。

このページでは Pattern 1:ハンドル指定+タッチ対応の基本並べ替えPattern 2:並び順をlocalStorageに保存してリロード後も復元 の2パターンを紹介します。

  • SortableJSによるドラッグ&ドロップ — CDN 1本の読み込みだけでハンドル・並べ替え・ドラッグ中の視覚フィードバックを実装できる
  • ドラッグハンドル指定handle オプションで指定した要素(⠿アイコン)だけをドラッグの起点にする
  • タッチ操作対応 — スマホ・タブレットでも追加コードなしでタッチ&ドラッグの並べ替えができる(HTML5 Drag & Drop APIにはない利点)
  • 並び順の保存・復元onEnd イベントで並び順を取得し localStorage に保存。ページをリロードしても並び順が復元される
  • 自作版(data-list-3)との方式比較 — 依存・タッチ対応・実装量・並び順保存のしやすさを比較表で整理し、どちらを使うべきかの判断材料を示す

自作版(HTML5 Drag & Drop API)との比較

同じ「リスト並べ替え」でも、HTML5標準の Drag & Drop API だけで自作した data-list-3 とは実装コストと対応範囲が大きく異なります。

観点 自作版(data-list-3・HTML5 Drag & Drop API) SortableJS(本ページ)
依存 なし(追加ライブラリ不要) CDN 1本(jsDelivr)
タッチデバイス対応 不可(別途 touchstart / touchmove の実装が必要) 標準で対応(追加コード不要)
実装コード量 多め(dragstart / dragover / drop 等を自前実装) 少ない(初期化オプションを渡すだけ)
並び順の保存 DOM順序を自前で取得するコードが必要 onEnd イベントでIDの配列をそのまま取得できる

実装のポイント・注意点

SortableJSの初期化は new Sortable(要素, オプション) の1行で完結します。 handle オプションにハンドル要素のクラス名を渡すと、そのハンドル以外をつかんでもドラッグが始まらなくなります。 ghostClass オプションはドラッグ中に付与されるクラス名で、ここで半透明などの見た目を指定します。 スマートフォンでも追加コードなしでタッチ操作によるドラッグができるのは、SortableJSが内部でPointer Eventsを利用しているためです。

並び順を保存する場合は onEnd イベント内で子要素の data-id を順番に取得し、 localStorage.setItem() で保存します。ただしプライベートブラウジング等で localStorage が 使えない環境もあるため、保存処理は try-catch で囲み、失敗しても並べ替え自体は動作するようにしておくと安全です。 またCDNのURLは @1.15.7 のようにバージョン番号を固定し、@latest は使わないようにします。 ライブラリが更新されてもデモの挙動が予告なく変わらないようにするためです。

HTML・CSS・JavaScriptで実装しており、SortableJSライブラリ(CDN)を1本追加するだけで動きます。フレームワーク不要でコピペすぐに使えます。

デモ

Pattern 1 — ハンドル指定・タッチ対応

    Pattern 2 — 並び順の保存・復元

      サンプルソース

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

      <!DOCTYPE html>
      <html lang="ja">
      <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>リスト(SortableJSで並べ替え)サンプル</title>
        <link rel="stylesheet" href="./style.css">
        <script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script>
      </head>
      <body>
      
      <section>
        <p class="pattern-label">Pattern 1 — ハンドル指定・タッチ対応</p>
        <ul class="lst" id="js-list-1"></ul>
        <div class="controls">
          <button id="js-reset-1" type="button">リセット</button>
        </div>
      </section>
      
      <section>
        <p class="pattern-label">Pattern 2 — 並び順の保存・復元</p>
        <ul class="lst" id="js-list-2"></ul>
        <p class="status" id="js-status-2"></p>
        <div class="controls">
          <button id="js-reload-2" type="button">リロード</button>
          <button id="js-reset-2" type="button">リセット</button>
        </div>
      </section>
      
      <script src="./script.js"></script>
      </body>
      </html>
      /* リスト(SortableJSで並べ替え)— 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;
      }
      
      section { max-width: 560px; margin-bottom: 32px; }
      
      .pattern-label {
        font-size: 12px;
        font-weight: 700;
        color: #2B7FE8;
        margin: 0 0 8px;
      }
      
      /* リスト本体 */
      .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;
      }
      
      /* リストアイテム */
      .lst-item {
        display: flex;
        align-items: center;
        gap: 12px;
        padding: 12px 16px;
        border-bottom: 1px solid #E5E9F2;
      }
      
      .lst-item:last-child { border-bottom: none; }
      
      /* ドラッグ中:SortableJSのghostClassで付与 */
      .lst-item.is-ghost { opacity: 0.4; }
      
      /* ドラッグハンドル */
      .lst-handle {
        flex-shrink: 0;
        color: #CBD5E1;
        font-size: 18px;
        cursor: grab;
        padding: 0 4px;
        line-height: 1;
      }
      
      .lst-handle:active { cursor: grabbing; }
      
      /* アバター */
      .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;
      }
      
      .status {
        font-size: 13px;
        color: #6B7280;
        margin: 8px 0 0;
      }
      
      .controls {
        margin-top: 12px;
        display: flex;
        justify-content: flex-end;
        gap: 8px;
      }
      
      .controls button {
        padding: 6px 16px;
        font-size: 13px;
        font-family: sans-serif;
        color: #5A6A7A;
        background: #fff;
        border: 1.5px solid #D0D7E0;
        border-radius: 6px;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s;
      }
      
      .controls button:hover {
        background: #F4F6F9;
        border-color: #9AA5B4;
      }
      
      @media (max-width: 480px) {
        body { padding: 12px; }
        section { max-width: 100%; }
      }
      var AVATAR_COLORS = ['1', '2', '3', '4', '5'];
      var STORAGE_KEY = 'sortable-list-pattern2-order';
      var allRows = [];
      
      // JSONを読み込んで両パターンを描画する
      fetch('./data/data.json')
        .then(function (res) {
          if (!res.ok) { throw new Error('HTTP ' + res.status); }
          return res.json();
        })
        .then(function (data) {
          allRows = data.rows;
          initPattern1();
          initPattern2();
        })
        .catch(function (err) {
          console.error('データ読み込みエラー:', err);
        });
      
      // リストを描画する(textContentのみ使用しXSSを防ぐ)
      function renderList(ul, rows) {
        ul.innerHTML = '';
        rows.forEach(function (row, index) {
          var li = document.createElement('li');
          li.className = 'lst-item';
          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);
        });
      }
      
      // Pattern 1: ハンドル指定・タッチ対応の基本並べ替え
      function initPattern1() {
        var ul = document.getElementById('js-list-1');
        renderList(ul, allRows);
      
        new Sortable(ul, {
          handle: '.lst-handle',
          ghostClass: 'is-ghost',
          animation: 150
        });
      
        document.getElementById('js-reset-1').addEventListener('click', function () {
          renderList(ul, allRows);
        });
      }
      
      // Pattern 2: 並び順をlocalStorageに保存して復元
      function initPattern2() {
        var ul = document.getElementById('js-list-2');
        var statusEl = document.getElementById('js-status-2');
        var rows = getOrderedRows();
      
        if (rows !== allRows) {
          statusEl.textContent = '保存済みの並び順を復元しました';
        }
        renderList(ul, rows);
      
        new Sortable(ul, {
          handle: '.lst-handle',
          ghostClass: 'is-ghost',
          animation: 150,
          onEnd: function () { saveOrder(ul, statusEl); }
        });
      
        document.getElementById('js-reload-2').addEventListener('click', function () {
          renderList(ul, getOrderedRows());
          statusEl.textContent = '保存されている並び順を再読み込みしました';
        });
      
        document.getElementById('js-reset-2').addEventListener('click', function () {
          try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* 無視 */ }
          renderList(ul, allRows);
          statusEl.textContent = '';
        });
      }
      
      // 保存済みIDの順序でrowsを並び替える。保存値がなければ元の配列を返す
      function getOrderedRows() {
        var savedIds = null;
        try {
          var raw = localStorage.getItem(STORAGE_KEY);
          if (raw) { savedIds = JSON.parse(raw); }
        } catch (e) { savedIds = null; }
      
        if (!savedIds) { return allRows; }
      
        var byId = {};
        allRows.forEach(function (row) { byId[row.id] = row; });
        return savedIds.map(function (id) { return byId[id]; }).filter(Boolean);
      }
      
      // 現在の並び順をlocalStorageに保存する
      function saveOrder(ul, statusEl) {
        var order = Array.from(ul.children).map(function (li) { return li.dataset.id; });
        try {
          localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
          var now = new Date();
          var hh = String(now.getHours()).padStart(2, '0');
          var mm = String(now.getMinutes()).padStart(2, '0');
          statusEl.textContent = '並び順を保存しました ' + hh + ':' + mm;
        } catch (e) {
          statusEl.textContent = 'この環境では並び順を保存できません(プライベートブラウジング等)';
        }
      }
      {
        "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用プロンプト

      各パターンのプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

      ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や列数変更など、要件を追記して使うのがおすすめです。

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

      💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

      Pattern 1 — ハンドル指定・タッチ対応の基本並べ替え

      # リスト並べ替え(SortableJS・ハンドル指定)作成依頼
      
      ## 概要
      SortableJS(CDN経由)を使って、リストアイテムをドラッグ&ドロップで並べ替えられるUIを実装してください。
      ドラッグの起点は専用のハンドルアイコンに限定し、スマホでのタッチ操作にも対応させます。
      
      ## 要件
      - 各リストアイテムにドラッグハンドル(グリップアイコン ⠿)を設ける。ハンドル以外の部分をつかんでもドラッグは始まらない
      - スマホ・タブレットでもハンドルをタッチしてドラッグすれば並べ替えできること
      - ドラッグ中のアイテムは半透明にする
      - リセットボタンで元の順序に戻す
      
      ## 技術仕様
      - HTML / CSS / バニラJavaScript で実装
      - 外部ライブラリ:SortableJS(CDN: https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js)
      - レスポンシブ対応:必要
      
      ## 動作詳細
      リストの <ul> を SortableJS の対象にし、handle オプションでハンドルのクラス名を指定してください。
      ghostClass オプションでドラッグ中の半透明表示クラスを指定してください。
      リストの初期データはJSでの配列またはfetchしたJSONから生成し、動的なDOM挿入は textContent を使い innerHTML に変数を直接渡さないでください。
      
      ## 出力形式
      HTML・CSS・JavaScriptを分けて出力してください。
      各ファイルは単独でコピー&ペーストして使えるよう記述してください。

      Pattern 2 — 並び順をlocalStorageに保存して復元

      # リスト並べ替え(SortableJS・並び順の保存)作成依頼
      
      ## 概要
      SortableJS(CDN経由)を使ったドラッグ&ドロップ並べ替えリストで、並べ替えた順序を localStorage に保存し、
      ページを再読み込みしても並び順が復元されるようにしてください。
      
      ## 要件
      - ドラッグハンドルで並べ替えができること
      - 並べ替えが完了したタイミング(onEndイベント)で、現在の並び順(IDの配列)を localStorage に保存すること
      - 保存したら「並び順を保存しました」等のステータスメッセージを表示すること
      - ページ読み込み時に localStorage の保存値があれば、その順序でリストを描画すること(保存値がなければ初期順)
      - リセットボタンで初期順序に戻し、保存済みの localStorage の値も削除すること
      - localStorage が使用できない環境でもエラーにならず、並べ替え自体は動作すること(try-catchで保存処理のみ無効化)
      
      ## 技術仕様
      - HTML / CSS / バニラJavaScript で実装
      - 外部ライブラリ:SortableJS(CDN: https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js)
      - レスポンシブ対応:必要
      
      ## 動作詳細
      SortableJS の onEnd コールバック内で、リストの子要素から data-id 属性を順番に取得して配列化し、
      JSON.stringify して localStorage に保存してください。
      初期描画時は localStorage の保存値をパースし、該当IDの順に並び替えたデータでリストを描画してください。
      動的なDOM挿入は textContent を使い innerHTML に変数を直接渡さないでください。
      
      ## 出力形式
      HTML・CSS・JavaScriptを分けて出力してください。
      各ファイルは単独でコピー&ペーストして使えるよう記述してください。