GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種

ドラッグアンドドロップ 並び替えは、指を離すたびに座標を計算し直す一枚の層です。ポインター位置から挿入先インデックスを計算し、リスト・カンバン・グリッドが並び替わります。ロングプレス・Alt矢印・端での自動スクロールまで — ドラッグ並び替え9種、コードはパスワードzipで。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

デモ

9項目ともpointerdown・pointermove・pointerupの3段階とsetPointerCaptureという骨格を共有していますが、その上に乗る計算(FLIP座標の逆算・挿入位置の逆算・距離から速度へのマッピング)は項目ごとに違います。実測値は次の表のとおりです。

番号 項目 累積面積 強度平均 動いたフレーム
01 FLIPリスト 6.12% 104.5 12/23
02 カンバンカード 25.11% 67.7 7/23
03 グリッド並び替え 21.30% 65.0 12/23
04 ハンドル専用 34.74% 42.0 7/23
05 ロングプレス判定 21.08% 97.0 16/23
06 キーボード並び替え 15.12% 85.5 8/23
07 端の自動スクロール 41.66% 182.5 12/23
08 ゴーストプレビュー 25.04% 140.0 17/23
09 プレースホルダー 25.85% 114.3 13/23

07の端の自動スクロールが累積面積41.66%で最も広く、リスト全体がスクロールで押し流されるためです。01のFLIPリストが6.12%で最も狭いのは、動く行が数行に限られる構造上の値です。02のカンバンカードと04のハンドル専用が24フレームのうち7フレームしか動かないのは欠陥ではなく、カラム変更や取っ手の慣性収束が自動再生の各ステップの間に待機区間を実際に持つためで、強度平均は04が42.0で最も落ち着き07が182.5で最も明るく、9項目とも下限(累積面積0.08%以上・強度15以上・動いたフレーム6以上)に十分な余裕をもって収まります。

ドラッグ並び替えの解剖 — pointerdown・move・upの3段階とsetPointerCapture

javascript ドラッグアンドドロップ 並び替えを自作するとき、最初に決めるべきは見た目より先にポインター処理の段階です。HTML5のdraggable="true" + dragstart系のDrag and Drop APIに置き換えたり、mousedown/touchstartを別に二重処理したりせず、pointerdown・pointermove・pointerup・pointercancelの4イベントだけでマウス・タッチ・ペンを統一して扱います。

pointerdownでsetPointerCaptureを呼ぶ理由

pointerdownが来た要素でtarget.setPointerCapture(e.pointerId)を呼ぶと、以降のmove・upがそのポインターIDに固定され、指がその要素の外に出てもイベントが途切れません。

pointermoveで挿入先インデックスを逆算する層

pointermoveごとに現在のポインター座標を読み、行のgetBoundingClientRectや列・行の幅で除算した商から挿入先インデックスを逆算し直すのが、このシリーズ共通の一枚です。

pointerupとpointercancelで終了と取消を分ける

pointerupは並び替えを確定してFLIPで解放し、pointercancelはロングプレスのタイマーやゴーストの角度をリセットして何も並び替えないまま終わります。

この3段階の上に重なる計算1枚

同じ3段階でも、01はFLIP差分・03は行列インデックス・05は待機時間と移動閾値・07は距離から速度へのマッピングという、項目ごとに違う計算が重なります。

9つのリストの段階別コード

html リスト 並び替え ドラッグを検索して来た人は、まずこの9項目の段階別コードから探すと早いです。自動再生はperformance.now()の時計が決めたポインター順序だけを再生するため、キャプチャごとに同じ場面が再現されます。

01FLIPリスト — 移動前後の座標差を計算し戻してから解放する並び替え

ドロップ直後に各行のgetBoundingClientRectを再測定し、以前の位置との差をtransformで即座に打ち消してから、次のフレームでtransitionで0に解放すると、行が自然に滑って収まります。DUR 180ms・cubic-bezier(.2,.8,.2,1)で、累積面積6.12%・強度平均104.5・動いたフレーム12/23です。

FLIPリスト — 移動前後の座標差を計算し戻してから解放する並び替え
新しいタブで開く

01 · FLIPリスト — 移動前後の座標差を計算し戻してから解放する並び替え — 計測 累積面積 6.12% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRecttransformtransition

getBoundingClientRectの差分をtransformで打ち消すapplyOrder

行を並べ替えたあとの差分を反転し、requestAnimationFrameの次のフレームでtransitionを有効にして0へ戻す順序を、逆にしないことがこの関数の要点です。

    for (var j = 0; j < rs.length; j++) {
      var dy = first[order[j]] - rs[j].getBoundingClientRect().top; // 反転
      if (Math.abs(dy) > Math.abs(maxAbs)) maxAbs = dy;
      if (dy !== 0) {
        rs[j].style.transition = 'none';
        rs[j].style.transform = 'translateY(' + dy + 'px)';
      }
    }
    deltaEl.textContent = 'dy ' + Math.round(maxAbs) + 'px';
    // 再生 — 次のフレームで戻しを解放する
    requestAnimationFrame(function () {
      rs.forEach(function (r) {
        r.style.transition = 'transform ' + DUR + 'ms cubic-bezier(.2,.8,.2,1)';
        r.style.transform = 'translateY(0)';
      });
      setTimeout(function () { rs.forEach(function (r) { r.style.transition = ''; }); }, DUR + 40);
    });

02カンバンカード — ポインターY比率から対象カラムと挿入位置を逆算する計算

ポインターがどのカラムのgetBoundingClientRect内にあるかで対象カラムを見つけ、そのカラム内カードのY中点とポインターYを比較して挿入位置を逆算します。カラムが実際に変わった瞬間だけ配列のspliceを実行し、累積面積25.11%・強度平均67.7・動いたフレーム7/23です。

カンバンカード — ポインターY比率から対象カラムと挿入位置を逆算する計算
新しいタブで開く

02 · カンバンカード — ポインターY比率から対象カラムと挿入位置を逆算する計算 — 計測 累積面積 25.115% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovegetBoundingClientRecttransition

findColumnとinsertIndexで対象カラムと挿入位置を求める

対象カラムを先に確定してからカード中点との比較に進む順序が、同じカラム内ではプレビューだけを動かしspliceを呼ばない理由です。

  function findColumn(x) {
    for (var i = 0; i < COLS.length; i++) {
      var r = COLS[i].getBoundingClientRect();
      if (x >= r.left && x <= r.right) return i;
    }
    return -1;
  }

  // 挿入インデックスの逆算 — カードのY中点とポインターYを比較
  function insertIndex(colList, y) {
    var cards = colList.children;
    for (var i = 0; i < cards.length; i++) {
      var r = cards[i].getBoundingClientRect();
      if (y < r.top + r.height / 2) return i;
    }
    return cards.length;
  }

03グリッド並び替え — ポインター座標から行・列インデックスを逆算する2次元計算

グリッドのトラック幅・行高でポインターのx・yを除算し、その商から列・行の整数インデックスを求めてMath.max/minで境界にクランプしてから、1次元の順序配列に平坦化してspliceします。累積面積21.30%・強度平均65.0・動いたフレーム12/23です。

グリッド並び替え — ポインター座標から行・列インデックスを逆算する2次元計算
新しいタブで開く

03 · グリッド並び替え — ポインター座標から行・列インデックスを逆算する2次元計算 — 計測 累積面積 21.295% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CSS custom propertiesgetBoundingClientRecttransition

cellAtでポインター座標を行・列インデックスに変換する

除算の商をそのまま使わずクランプを挟むこの2行が、グリッド外に落としても最後のセルへ安全に収まる理由です。

  function cellAt(x, y) {
    var r = board.getBoundingClientRect();
    var col = Math.floor((x - r.left) / (r.width / COLS));
    var row = Math.floor((y - r.top) / (r.height / Math.ceil(CELLS.length / COLS)));
    col = Math.max(0, Math.min(COLS - 1, col));
    row = Math.max(0, Math.min(Math.ceil(CELLS.length / COLS) - 1, row));
    return { col: col, row: row, idx: row * COLS + col };
  }

04ハンドル専用 — 小さな取っ手だけにポインターキャプチャをかけ行の残りを生かす分離

行全体ではなく取っ手アイコンだけにpointerdownを付け、setPointerCaptureでその後の移動をその要素に固定しながらLERP係数で目標位置へ徐々に収束させます。累積面積34.74%・強度平均42.0・動いたフレーム7/23です。

ハンドル専用 — 小さな取っ手だけにポインターキャプチャをかけ行の残りを生かす分離
新しいタブで開く

04 · ハンドル専用 — 小さな取っ手だけにポインターキャプチャをかけ行の残りを生かす分離 — 計測 累積面積 34.737% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setPointerCapturepointerdownCSS custom properties

beginDrag・moveDrag・tickでLERP追従を作る

drag.pos += (drag.target - drag.pos) * LERPだけで、取っ手がわずかな慣性を伴って追従する動きが生まれます。

  function beginDrag(row, clientY) {
    var r = row.getBoundingClientRect();
    row.classList.add('ho__row--drag');
    drag = { row: row, offsetInRow: clientY - r.top, pos: 0, target: 0 };
    tick();
  }

  function moveDrag(clientY) {
    if (!drag) return;
    var listRect = list.getBoundingClientRect();
    drag.target = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
    reflow(drag.row, clientY);
  }

  function tick() {
    if (!drag) return;
    drag.pos += (drag.target - drag.pos) * LERP;
    drag.row.style.setProperty('--ty', drag.pos.toFixed(2) + 'px');
    drag._raf = requestAnimationFrame(tick);
  }

05ロングプレス判定 — 待機時間と移動閾値でスクロールとドラッグを分ける計算

pointerdownでタイマーを開始し、HOLD_MSが経過してからドラッグモードへ切り替え、その間にpointermoveの累積移動距離がMOVE_PXを超えればスクロール意図と判断してタイマーを取り消します。累積面積21.08%・強度平均97.0・動いたフレーム16/23です。

ロングプレス判定 — 待機時間と移動閾値でスクロールとドラッグを分ける計算
新しいタブで開く

05 · ロングプレス判定 — 待機時間と移動閾値でスクロールとドラッグを分ける計算 — 計測 累積面積 21.082% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

setTimeoutpointermovepointercancel

startPress・cancelPress・activateでタイマーと閾値を扱う

pointercancelがタイマーより先に来ればcancelPressだけが呼ばれ、ただのタップとして終わります。

  function startPress(row, x, y) {
    cancelPress();
    press = { row: row, startX: x, startY: y, pressStart: performance.now(), raf: null, timer: null };
    press.timer = setTimeout(function () { activate(row, y); }, HOLD_MS);
    pressTick();
  }

  function cancelPress() {
    if (!press) return;
    if (press.timer) clearTimeout(press.timer);
    if (press.raf) cancelAnimationFrame(press.raf);
    press = null;
  }

  function activate(row, y) {
    if (press && press.row === row) cancelPress();
    var r = row.getBoundingClientRect();
    row.classList.add('lp__row--drag');
    stateEl.textContent = 'ドラッグ';
    active = { row: row, offsetInRow: y - r.top };
  }

06キーボード並び替え — Alt+矢印で配列インデックスを移し、aria-liveで知らせる計算

フォーカスされた行でAlt+↑/↓を押すと配列の現在のインデックスと隣のインデックスをswapし、結果をaria-live領域に「n番目に移動」という文で知らせます。移動自体は同じFLIPの層(transition 220ms)で見せ、累積面積15.12%・強度平均85.5・動いたフレーム8/23です。

キーボード並び替え — Alt+矢印で配列インデックスを移し、aria-liveで知らせる計算
新しいタブで開く

06 · キーボード並び替え — Alt+矢印で配列インデックスを移し、aria-liveで知らせる計算 — 計測 累積面積 15.117% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

keydownaria-liveCSS custom properties

flipSwapでAlt+矢印の配列swapとFLIPを重ねる

配列のswapを先に確定してから座標差を測るため、マウスなしの移動でも01と同じ滑る動きになります。

  function flipSwap(row, dir) {
    var arr = rows();
    var i = arr.indexOf(row);
    var j = i + dir;
    if (j < 0 || j >= arr.length) return i;
    var first = arr.map(function (r) { return r.getBoundingClientRect(); });
    if (dir > 0) list.insertBefore(arr[j], row);
    else list.insertBefore(row, arr[j]);
    arr.forEach(function (r, k) {
      var rr = r.getBoundingClientRect();
      var dy = first[k].top - rr.top;
      if (dy !== 0) {
        r.style.transition = 'none';
        r.style.transform = 'translateY(' + dy + 'px)';
        requestAnimationFrame(function () {
          r.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
          r.style.transform = 'translateY(0)';
        });
      }
    });
    return j;
  }

07端の自動スクロール — 距離を速度にマッピングするrAFループ

ドラッグ中にポインターがコンテナ上下端の内側EDGE_PXの帯に入ると、その端までの残り距離を0〜1に正規化してMAX_SPEEDを掛けた値を、毎フレームscrollByへ渡すrequestAnimationFrameループで流します。EDGE_PX 40・MAX_SPEED 480px/sで、累積面積41.66%・強度平均182.5・動いたフレーム12/23です。

端の自動スクロール — 距離を速度にマッピングするrAFループ
新しいタブで開く

07 · 端の自動スクロール — 距離を速度にマッピングするrAFループ — 計測 累積面積 41.662% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramescrollBygetBoundingClientRect

edgeFactorとtickで端までの距離を速度に変える

帯を外れた瞬間edgeFactorが0を返すので、そのフレームでスクロールが止まります。

  function edgeFactor(pointerY, viewRect) {
    var topDist = pointerY - viewRect.top;
    var botDist = viewRect.bottom - pointerY;
    if (topDist >= 0 && topDist < EDGE_PX) return -(1 - topDist / EDGE_PX);
    if (botDist >= 0 && botDist < EDGE_PX) return (1 - botDist / EDGE_PX);
    return 0;
  }

  function tick(now) {
    if (!drag) { raf = null; stateEl.textContent = 'speed 0px/s'; return; }
    var dt = lastT ? (now - lastT) / 1000 : 0;
    lastT = now;
    var viewRect = view.getBoundingClientRect();
    var f = edgeFactor(drag.pointerY, viewRect);
    var speed = f * MAX_SPEED;
    if (speed !== 0 && dt > 0) view.scrollBy(0, speed * dt);
    stateEl.textContent = 'speed ' + Math.round(Math.abs(speed)) + 'px/s';
    raf = requestAnimationFrame(tick);
  }

08ゴーストプレビュー — 移動速度を傾きに変える残像コピー

ポインターに追従する複製要素の位置をpointermoveごとに更新しながら、直前フレームとのx移動量の差を定数Kで角度に変換してtransform: rotateで傾け、MAX_ANGLEでクランプします。LERP 0.25・K 2.4・MAX_ANGLE 18degで、累積面積25.04%・強度平均140.0・動いたフレーム17/23です。

ゴーストプレビュー — 移動速度を傾きに変える残像コピー
新しいタブで開く

08 · ゴーストプレビュー — 移動速度を傾きに変える残像コピー — 計測 累積面積 25.037% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermovetransform: rotatetransition

moveDragでx移動量を角度に変換しrotateへ渡す

dx * KをMAX_ANGLEでクランプしたあとにdrag.targetへ渡すため、reflow側のLERPが角度を滑らかに追いかけます。

  function moveDrag(clientX, clientY) {
    if (!drag) return;
    var listRect = list.getBoundingClientRect();
    var ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
    drag.row.style.setProperty('--ty', ty.toFixed(1) + 'px');
    var dx = clientX - drag.lastX;
    drag.lastX = clientX;
    var t = dx * K;
    if (t > MAX_ANGLE) t = MAX_ANGLE;
    if (t < -MAX_ANGLE) t = -MAX_ANGLE;
    drag.target = t;
    reflow(drag.row, clientY);
  }

09プレースホルダー — 持ち上げた行の高さを移して膨らませる計算

ドラッグを始めた行のgetBoundingClientRectの高さをCSSカスタムプロパティ(--h)に保存し、プレースホルダーのheight: var(--h)へ渡して正確に同じ大きさの空きを作ります。累積面積25.85%・強度平均114.3・動いたフレーム13/23です。

プレースホルダー — 持ち上げた行の高さを移して膨らませる計算
新しいタブで開く

09 · プレースホルダー — 持ち上げた行の高さを移して膨らませる計算 — 計測 累積面積 25.853% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transitionCSS custom propertiesgetBoundingClientRect

beginDragで持ち上げた行の高さを--hに保存する

高さを読むタイミングをlist.insertBeforeより先にするため、プレースホルダーは持ち上げた瞬間の高さをそのまま受け取ります。

  function beginDrag(row, clientY) {
    var r = row.getBoundingClientRect();
    var listRect = list.getBoundingClientRect();
    placeholder.style.setProperty('--h', r.height + 'px');
    list.insertBefore(placeholder, row);
    row.classList.add('dd__row--drag');
    row.style.setProperty('--rowTop', (r.top - listRect.top) + 'px');
    row.style.setProperty('--rowW', r.width + 'px');
    row.style.setProperty('--ty', '0px');
    drag = { row: row, offsetInRow: clientY - r.top, listRect: listRect };
    var idx = Array.prototype.indexOf.call(list.children, placeholder);
    stateEl.textContent = 'slot ' + (idx + 1);
  }

位置は計算だ — FLIP座標の逆算・自動スクロール速度・ロングプレス閾値の式

react ドラッグアンドドロップ 並び替えのライブラリを使わずに素のDOMで書くときも、この9種を貫くのはライブラリではなく式です。見た目の効果そのものではなく、ポインター座標を再処理してインデックス・プレースホルダー・スクロール速度に反映する一枚が、この回の一重の前提になります。

FLIP座標の逆算の式 — dyでtransformを打ち消しtransitionで0へ戻す

dy = 移動前のtop - 移動後のtopを先にtransformへ反映してから、次のフレームでtransitionを有効にして0へ戻します。順序を逆にすると、瞬間的にスナップするはずの開始地点自体がアニメーションに見えてしまいます。

自動スクロール速度の式 — 0〜1に正規化した距離にMAX_SPEEDを掛ける

端までの残り距離を1 - dist / EDGE_PXで0〜1に正規化し、MAX_SPEEDを掛けた値だけを毎フレームscrollByに渡します。正規化を省くと、帯にわずかに触れただけで最大速度に跳んで制御できなくなります。

ロングプレス閾値の式 — HOLD_MSとMOVE_PXでスクロールとドラッグを分ける

pointerdownからの経過時間がHOLD_MSを超え、かつ累積移動距離がMOVE_PXを超えていないときだけドラッグへ切り替わります。時間だけで判定すると、スクロールしようと触れた瞬間でもドラッグと誤認されてしまいます。

グリッド・カンバンの挿入位置逆算 — 座標の除算・比較からindexを求める

グリッドはポインターx・yをトラック幅・行高で除算した商を、カンバンはカードのY中点とポインターYの比較を、それぞれ挿入先indexへ逆算します。zip内のvanillaフォルダをそのまま開けば、hyvft5vmを解凍キーにして09までの完成コードも確認できます。

並び替えを動かすこの9種、どの画面に置くか

やることリストの優先度並び替え、カンバンボードのカード移動、設定メニューの項目順序編集 — この3つがドラッグ並び替えを最もよく求める画面です。01のFLIPリストはやることリストのように1件動くだけでも全体が自然に動くべき画面に合い、02のカンバンカードは列をまたぐ移動まで受け止める必要があるボードに合います。

ポインターを使えない訪問者が設定メニューの順序を変える画面では、06のキーボード並び替えがAlt+矢印だけで同じ結果を出します。3つの画面に共通するのは、ポインター操作とキーボード操作が同じ配列を指す必要がある点で、両方を1つの状態にまとめるこの構造がそのまま当てはまります。

どこで壊れるか — タッチスクロールの衝突・キーボード操作・長いリストの性能

タッチスクロールとの衝突

touch-actionを指定しないままpointer処理だけを付けると、タッチではページ自体のスクロールとドラッグ開始が競合します。ロングプレス判定(05)のようにMOVE_PXの移動閾値を挟むと、指がわずかに動いた瞬間はスクロール優先と判断できます。

キーボード操作とaria-live

マウス操作だけを実装してAlt+矢印(06)を省くと、キーボードだけの利用者は並び替えそのものができません。結果をaria-live="polite"領域に文で知らせないと、視覚的には行が動いても読み上げには何も伝わりません。

長いリストのgetBoundingClientRect性能

pointermoveのたびに全行のgetBoundingClientRectを呼ぶと、行数が増えるほど再計算のコストが増えます。ドラッグ中の対象だけをreflowして他の行の再測定を最小限にすると、長いリストでも滑らかさを保てます。

判定時間と速度を調整するとアクセシビリティも一緒に良くなる

ロングプレス待機時間 250ms→400ms

05のHOLD_MSを250msから400msに伸ばすと、スクロールの誤判定は減りますが、ドラッグ開始が一拍遅く感じられます。250msは手の動きが遅い利用者にも速く反応します。

自動スクロール最大速度 480px/s→240px/s

07のMAX_SPEEDを480px/sから240px/sに落とすと、制御感は増しますが非常に長いリストでは遅く感じます。480px/sは長いリストを速くなぞれる代わりに目標の行を通り過ぎやすくなります。

FLIP解放時間 180ms→320ms

01のDURを180msから320msに伸ばすと、複数の行が同時に押される動きを目で追いやすくなります。180msは並び替えが即座に感じられます。

prefers-reduced-motionでも計算は止めない

prefers-reduced-motion: reduceはmatchMediaで検出してtransitionだけを切り、最終状態を即座に見せます。FLIPの差分計算・挿入位置の逆算・境界のクランプ・距離から速度へのマッピングという計算自体は、減速の要求とは無関係に残します。

並び替えを付けるとよくある質問

html ドラッグアンドドロップ 並び替えにHTML5のdraggable属性は必要ですか

いいえ、必要ありません。この9種はすべてpointerdown・pointermove・pointerup・pointercancelとsetPointerCaptureだけで書かれ、draggable="true"やdragstart系のAPIは使いません。

パスワードzipのコードはこの記事の9項目とそのまま同じですか

はい、同じです。vanillaフォルダのindex.htmlが記事内のコードブロックと同じ関数名・同じ定数(DUR・LERP・HOLD_MSなど)で書かれています。

タッチデバイスでもこの9種はそのまま動きますか

はい。Pointer Eventsはマウス・タッチ・ペンを同じイベントで扱うため、05のロングプレス判定を含めてタッチでも同じ計算がそのまま動きます。

もっと読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。