AI先生のロボットキャラクター
第6章 - セクション1

オノマトペ付きソータブルリスト

ドラッグ&ドロップで並び替えでき、入れ替え時とドロップ時だけスルッ!ドン!などオノマトペが出るリストパーツ(Vanilla JS)

ToDo管理や優先順位の調整など、リストの並び替えは日常的によく使われる操作です。しかし、ただ項目が入れ替わるだけでは味気なく、操作している感覚も薄れてしまいます。

このパーツでは、ドラッグ&ドロップによる並び替えに「スルッ!」「ドン!」などのオノマトペを添えることで、操作に楽しさとフィードバックを加えます。視覚的なエフェクトだけでなく、言葉による表現が加わることで、UIに生き生きとした印象を与えられます。

作成したオノマトペ付きソータブルリスト

ドラッグ&ドロップで並び替えでき、入れ替え時とドロップ時だけスルッ!ドン!などオノマトペが出るリストパーツ(Vanilla JS)

HTML
<div class="list-wrapper">
        <ul id="sortable">
            <li class="sortable-item" data-key="A01"><span class="item-id">#01</span><span class="item-text">メールを確認する</span></li>
            <li class="sortable-item" data-key="A02"><span class="item-id">#02</span><span class="item-text">議事録をまとめる</span></li>
            <li class="sortable-item" data-key="A03"><span class="item-id">#03</span><span class="item-text">資料を更新する</span></li>
            <li class="sortable-item" data-key="A04"><span class="item-id">#04</span><span class="item-text">コードレビューをする</span></li>
            <li class="sortable-item" data-key="A05"><span class="item-id">#05</span><span class="item-text">バグを修正する</span></li>
            <li class="sortable-item" data-key="A06"><span class="item-id">#06</span><span class="item-text">公開前チェックをする</span></li>
        </ul>
    </div>
CSS
:root {
      --bg-color: #f4f4f5;
      --text-color: #333;
      --border-color: #e4e4e7;
      --item-bg: #fff;
      --accent-color: #6366f1;
    }

    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      min-height: 100vh;
      background-color: var(--bg-color);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      overflow-x: hidden;
      padding: 32px 16px;
    }

    body.is-dragging {
      user-select: none;
      cursor: grabbing;
    }

    /* コンテナ */
    .list-wrapper {
      width: 100%;
      max-width: 400px;
      background: var(--item-bg);
      border-radius: 16px;
      box-shadow: 0 10px 40px -10px rgba(0,0,0,0.1);
      overflow: hidden;
      margin: 0 auto;
    }

    /* ソータブルリスト */
    #sortable {
      list-style: none;
      padding: 12px;
      margin: 0;
      touch-action: none;
    }

    .sortable-item {
      background: #f4f4f5;
      padding: 16px 20px;
      margin-bottom: 8px;
      border-radius: 12px;
      border: 1px solid var(--border-color);
      color: var(--text-color);
      font-size: 16px;
      font-weight: 500;
      cursor: grab;
      display: flex;
      align-items: center;
      gap: 12px;
      transition:
        transform 0.2s cubic-bezier(0.25, 1, 0.5, 1),
        box-shadow 0.2s cubic-bezier(0.25, 1, 0.5, 1),
        border-color 0.2s cubic-bezier(0.25, 1, 0.5, 1);
      position: relative;
    }

    .item-id {
      flex: 0 0 auto;
      min-width: 40px;
      padding: 6px 10px;
      border-radius: 999px;
      border: 1px solid #d4d4d8;
      background: #fff;
      color: #52525b;
      font-size: 12px;
      font-weight: 700;
      letter-spacing: 0.02em;
      text-align: center;
    }

    .item-text {
      flex: 1;
    }

    #sortable .sortable-item:last-child {
      margin-bottom: 0;
    }

    #sortable .sortable-item:hover {
      border-color: #d4d4d8;
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(0,0,0,0.05);
    }

    #sortable .sortable-item:active {
      cursor: grabbing;
    }

    #sortable li.drag-source {
      display: none;
    }

    #sortable li.sortable-placeholder {
      background: #f4f4f5;
      border: 1px dashed #d4d4d8;
      border-radius: 12px;
      margin-bottom: 8px;
      position: relative;
      overflow: hidden;
    }

    /* 空白に見えないよう、プレースホルダーに薄い“行の残像”を出す */
    #sortable li.sortable-placeholder::before,
    #sortable li.sortable-placeholder::after {
      content: '';
      position: absolute;
      top: 50%;
      transform: translateY(-50%);
      height: 14px;
      border-radius: 999px;
      background: rgba(212, 212, 216, 0.75);
    }

    #sortable li.sortable-placeholder::before {
      left: 20px;
      width: 44px;
      height: 24px;
      background: rgba(212, 212, 216, 0.55);
    }

    #sortable li.sortable-placeholder::after {
      left: 84px;
      right: 20px;
    }

    /* ドラッグ中の本体(jQuery UIのhelper相当) */
    .dragging-item {
      position: fixed;
      pointer-events: none;
      z-index: 9999;
      margin: 0 !important;
      box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.12), 0 10px 10px -5px rgba(0, 0, 0, 0.06);
      border-color: var(--accent-color);
      opacity: 0.96;
      transform: none !important;
      transition: none !important;
      cursor: grabbing;
    }

    /* オノマトペ表示エリア */
    .onomatope {
      position: absolute;
      font-size: 24px;
      font-weight: 900;
      pointer-events: none;
      white-space: nowrap;
      z-index: 9999;
      color: #111;
      /* 漫画的な表現 */
      text-shadow: 
        2px 2px 0 #fff,
        -2px -2px 0 #fff,
        2px -2px 0 #fff,
        -2px 2px 0 #fff,
        3px 3px 0 rgba(0,0,0,0.1);
      animation: popIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
    }

    /* バリエーション */
    .style-1 { font-family: 'Kiwi Maru', serif; }
    .style-2 { font-family: 'Yusei Magic', sans-serif; }
    .style-3 { font-family: 'Potta One', cursive; }
    .style-4 { font-family: 'Dela Gothic One', cursive; }
    .style-5 { font-family: 'Rampart One', cursive; }
    .style-6 { font-family: 'RocknRoll One', sans-serif; }
    .style-7 { font-family: 'DotGothic16', sans-serif; }

    @keyframes popIn {
      0% {
        opacity: 0;
        transform: translate(-50%, -50%) scale(0.5) rotate(var(--rotate));
      }
      20% {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1.2) rotate(var(--rotate));
      }
      40% {
        transform: translate(-50%, -50%) scale(1) rotate(var(--rotate));
      }
      100% {
        opacity: 0;
        transform: translate(-50%, -60%) scale(0.9) rotate(var(--rotate));
      }
    }
JavaScript
// オノマトペを表示
    function displayOnomatope(text, x, y, scale = 1) {
      const div = document.createElement('div');
      div.className = 'onomatope';
      div.textContent = text;
      
      // ランダムなフォントスタイル
      const styleNum = Math.floor(Math.random() * 7) + 1;
      div.classList.add(`style-${styleNum}`);
      
      // 適切なサイズ
      const baseSize = 32; // 基本サイズを小さく調整
      div.style.fontSize = `${baseSize * scale}px`;
      
      // ランダムな回転角度 (-20deg ~ 20deg)
      const rotation = (Math.random() - 0.5) * 40;
      div.style.setProperty('--rotate', `${rotation}deg`);
      
      // 位置設定
      div.style.left = `${x}px`;
      div.style.top = `${y}px`;

      document.body.appendChild(div);
      
      // アニメーション終了後に削除
      setTimeout(() => {
        div.remove();
      }, 1000);
    }

    (function initCustomSortable() {
      const list = document.getElementById('sortable');
      if (!list) return;

      const changeTexts = ['クルン', 'シュッ', 'ピコッ', 'ゴゴゴ', 'ヒョイ', 'カサッ'];
      const stopTexts = ['ピタッ', 'ストン', 'ドン', 'パチッ'];

      let dragItem = null;
      let placeholder = null;
      let pointerId = null;
      let offsetY = 0;
      let offsetX = 0;
      let dragHeight = 0;
      let lastClientY = 0;
      let lastPlaceholderIndex = -1;

      function randomPick(arr) {
        return arr[Math.floor(Math.random() * arr.length)];
      }

      function getCenterOfElement(el) {
        const r = el.getBoundingClientRect();
        return {
          x: r.left + r.width / 2 + window.scrollX,
          y: r.top + r.height / 2 + window.scrollY,
          rect: r
        };
      }

      function movePlaceholderByPointer(probeY) {
        const items = Array.from(list.children).filter((el) => el !== placeholder);
        const target = items.find((el) => {
          const r = el.getBoundingClientRect();
          return probeY < r.top + r.height / 2;
        });

        if (target) {
          if (placeholder.nextSibling !== target) {
            list.insertBefore(placeholder, target);
          }
        } else {
          list.appendChild(placeholder);
        }

        const newIndex = Array.from(list.children).indexOf(placeholder);
        if (newIndex !== lastPlaceholderIndex) {
          lastPlaceholderIndex = newIndex;
          const c = getCenterOfElement(placeholder);
          displayOnomatope(randomPick(changeTexts), c.x, c.y);
        }
      }

      function cleanupDrag() {
        document.body.classList.remove('is-dragging');
        if (pointerId !== null) {
          try {
            list.releasePointerCapture(pointerId);
          } catch {
            // ignore
          }
        }
        if (dragItem) {
          dragItem.classList.remove('dragging-item');
          dragItem.style.left = '';
          dragItem.style.top = '';
          dragItem.style.width = '';
        }
        dragItem = null;
        placeholder = null;
        pointerId = null;
        lastPlaceholderIndex = -1;
      }

      list.addEventListener('pointerdown', (e) => {
        const li = e.target instanceof Element ? e.target.closest('li') : null;
        if (!li || !list.contains(li)) return;

        e.preventDefault();

        pointerId = e.pointerId;
  // 枠外に出ても追従し続けるよう、リスト側でポインタをキャプチャ
  list.setPointerCapture(pointerId);
        dragItem = li;

        const rect = li.getBoundingClientRect();
        offsetX = e.clientX - rect.left;
        offsetY = e.clientY - rect.top;
        dragHeight = rect.height;
        lastClientY = e.clientY;

        placeholder = document.createElement('li');
        placeholder.className = 'sortable-placeholder';
        placeholder.style.height = `${rect.height}px`;
        placeholder.style.borderRadius = getComputedStyle(li).borderRadius;

        // まずプレースホルダーを同じ位置に入れ、ドラッグ対象の<li>はbodyに退避させて追従させる
        list.replaceChild(placeholder, li);
        document.body.appendChild(li);

        dragItem.classList.add('dragging-item');
        dragItem.style.width = `${rect.width}px`;
        dragItem.style.left = `${rect.left}px`;
        dragItem.style.top = `${rect.top}px`;

        document.body.classList.add('is-dragging');
        lastPlaceholderIndex = Array.from(list.children).indexOf(placeholder);
      });

      list.addEventListener('pointermove', (e) => {
        if (!dragItem || e.pointerId !== pointerId || !placeholder) return;

        // 本体をカーソルに追従(jQuery UIの挙動に近い)
        const nextLeft = e.clientX - offsetX;
        const nextTop = e.clientY - offsetY;
        dragItem.style.left = `${nextLeft}px`;
        dragItem.style.top = `${nextTop}px`;

        // jQuery UIの体感に寄せる:
        // 下方向は「底」が相手の半分を超えたら、上方向は「上辺」が相手の半分を超えたら入れ替える
        const movingDown = e.clientY >= lastClientY;
        const probeY = movingDown ? (nextTop + dragHeight) : nextTop;
        movePlaceholderByPointer(probeY);
        lastClientY = e.clientY;
      });

      function finalize(e) {
        if (!dragItem || e.pointerId !== pointerId || !placeholder) return;

        // body上の要素を、プレースホルダー位置へ戻す(<li>ごと移動)
        list.replaceChild(dragItem, placeholder);

        const c = getCenterOfElement(dragItem);
        displayOnomatope(randomPick(stopTexts), c.x, c.y, 1.2);

        cleanupDrag();
      }

      list.addEventListener('pointerup', finalize);
      list.addEventListener('pointercancel', cleanupDrag);
      window.addEventListener('blur', cleanupDrag);
    })();

このパーツの特徴

  • Vanilla JSで実装 ライブラリ不要で軽量に動作するドラッグ&ドロップ機能
  • オノマトペ演出 入れ替え時に「クルン」「シュッ」、ドロップ時に「ドン!」などが表示
  • ランダムフォント 7種類の日本語フォントからランダムに選ばれ、毎回違った表情を演出
  • スムーズな操作感 プレースホルダーで挿入位置を明示し、直感的に並び替え可能
  • 漫画風デザイン text-shadowで白い縁取りを施し、コミカルな印象を強調

コードのポイント

オノマトペの表示ロジック

オノマトペはdisplayOnomatope()関数で要素の中心座標に表示されます。ランダムなフォントスタイルと回転角度で、毎回異なる表情を演出します。

function displayOnomatope(text, x, y, scale = 1) {
    const div = document.createElement('div');
    div.className = 'onomatope';
    div.textContent = text;
    
    // ランダムなフォントスタイル
    const styleNum = Math.floor(Math.random() * 7) + 1;
    div.classList.add(`style-${styleNum}`);
    
    // ランダムな回転角度 (-20deg ~ 20deg)
    const rotation = (Math.random() - 0.5) * 40;
    div.style.setProperty('--rotate', `${rotation}deg`);
    
    // 位置設定
    div.style.left = `${x}px`;
    div.style.top = `${y}px`;

    document.body.appendChild(div);
    
    // アニメーション終了後に削除
    setTimeout(() => {
        div.remove();
    }, 1000);
}

プレースホルダーの移動検知

ドラッグ中の位置に応じてプレースホルダーを移動させ、位置が変わったときだけオノマトペを表示します。

function movePlaceholderByPointer(probeY) {
    const items = Array.from(list.children).filter((el) => el !== placeholder);
    const target = items.find((el) => {
        const r = el.getBoundingClientRect();
        return probeY < r.top + r.height / 2;
    });

    if (target) {
        if (placeholder.nextSibling !== target) {
            list.insertBefore(placeholder, target);
        }
    } else {
        list.appendChild(placeholder);
    }

    const newIndex = Array.from(list.children).indexOf(placeholder);
    if (newIndex !== lastPlaceholderIndex) {
        lastPlaceholderIndex = newIndex;
        const c = getCenterOfElement(placeholder);
        displayOnomatope(randomPick(changeTexts), c.x, c.y);
    }
}

漫画風のオノマトペスタイル

text-shadowで白い縁取りを複数方向に配置し、漫画のような視覚効果を実現しています。

.onomatope {
    position: absolute;
    font-size: 24px;
    font-weight: 900;
    pointer-events: none;
    white-space: nowrap;
    z-index: 9999;
    color: #111;
    /* 漫画的な表現 */
    text-shadow: 
        2px 2px 0 #fff,
        -2px -2px 0 #fff,
        2px -2px 0 #fff,
        -2px 2px 0 #fff,
        3px 3px 0 rgba(0,0,0,0.1);
    animation: popIn 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}

ドラッグ要素のキャプチャ

Pointer APIのsetPointerCapture()を使うことで、カーソルがリスト外に出てもドラッグ操作を継続できます。

list.addEventListener('pointerdown', (e) => {
    const li = e.target instanceof Element ? e.target.closest('li') : null;
    if (!li || !list.contains(li)) return;

    e.preventDefault();

    pointerId = e.pointerId;
    // 枠外に出ても追従し続けるよう、リスト側でポインタをキャプチャ
    list.setPointerCapture(pointerId);
    dragItem = li;
    
    // ... (以下、ドラッグ処理)
});

まとめ

オノマトペ付きソータブルリストのポイント
  • 言葉による演出 視覚効果だけでなく、オノマトペで操作に楽しさを追加
  • Vanilla JS実装 ライブラリ不要で軽量かつカスタマイズしやすい構造
  • ランダム要素 フォントと回転角度のランダム化で、飽きのこない表現
  • プレースホルダー機能 挿入位置を視覚的に示し、直感的な操作を実現
  • Pointer API活用 タッチデバイスでも安定したドラッグ&ドロップ操作

オノマトペという日本語特有の表現を活かしたUIパーツです。遊び心のあるフィードバックが、ユーザーの操作体験をより楽しく、印象的なものにします。

学習チェック

このレッスンを理解できたら「完了」をクリックしてください。
後で見直したい場合は「未完了に戻す」で進捗をリセットできます。

レッスン完了!🎉

お疲れさまでした!