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

ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算

「ドラッグ アンド ドロップ 実装」の作り方は9通りあります。磁力グリッドが24px以内でアイコンを捕まえ、ゴムのように伸びた境界がその手をまた放し、指を離した瞬間の速度が残りの距離を押し進める。

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

ドラッグ アンド ドロップ 実装 9パターン — 指を離した後も続く計算

以下の9パターンはすべて同じ問いに別々の答えを出しています — ポインターが動いている間と、指を離した瞬間の間で、ドラッグ対象は何をすべきかという問いです。01番はポインターが押されている間だけ反応し、距離が基準を下回った瞬間にセルへ向かって吸着します。03番になると同じ反応が上限で頭打ちになり、05番と06番は指を離した後も何かが動き続け、09番はここまでの仕組み — グリッド磁石・ゴム境界・慣性フリング — の三つを一つの盤にまとめています。全体としては、ポインターが押されている間だけ意味を持つ値から、手を離した後もしばらく動きを決め続ける値へと移っていきます。

01セルに吸着するグリッド磁石

updateMagnetHighlight() はポインターが動くたびにドラッグ中のアイコンからグリッドの各セル中心までの距離を測っていて、その距離が GRID_MAGNET_SNAP_PX を下回った瞬間に最も近いセルへ is-target が付き、アイコンの位置は残り距離から算出した引き寄せ係数ぶんだけそのセルへ寄っていきます。その半径の内側で指を離すと settleTick() が SPRING_STIFFNESS と SPRING_DAMPING でアイコンを最後までセルへ運び、瞬間移動はしません。

距離内に入るとセルが引き寄せるグリッド磁力ドラッグ
新しいタブで開く

01 · 距離内に入るとセルが引き寄せるグリッド磁力ドラッグ — 計測 累積面積 14.062% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRID_MAGNET_SNAP_PXSPRING_STIFFNESSSPRING_DAMPING
  function updateMagnetHighlight(rawX, rawY) {
    var near = nearestCell();
    cells.forEach(function (c) { c.classList.remove('is-target'); });
    if (near.dist < GRID_MAGNET_SNAP_PX) {
      near.cell.classList.add('is-target');
      targetCell = near;
      var pull = Math.max(0, 1 - near.dist / GRID_MAGNET_SNAP_PX) * 0.55;
      pos.x = rawX + (near.offset.x - rawX) * pull;
      pos.y = rawY + (near.offset.y - rawY) * pull;
    } else {
      targetCell = null;
      pos.x = rawX; pos.y = rawY;
    }
    applyPos();
  }

  var raf = 0, lastTs = null;
  function settleTick(ts) {
    if (lastTs === null) lastTs = ts;

01番は累積面積14.1%・平均強度177.0を記録し、動いたのは20/23フレームです。

02起点まで伸びて戻る弾性テザー

drawTether() は毎フレーム、アイコンと起点それぞれの実測の境界矩形から線を引き直していて、一つのdiv要素を回転させて二点間の距離と角度に合わせています。その同じ距離が TETHER_THICKNESS_BASE_PX を通じて線の太さも決めていて、指を離すと settleTick() が TETHER_SPRING_STIFFNESS と TETHER_SPRING_DAMPING でアイコンを起点へ向けてばねのように戻し、線の長さがゼロになるまで続きます。

原点とロープでつながったように伸びる弾性テザードラッグ
新しいタブで開く

02 · 原点とロープでつながったように伸びる弾性テザードラッグ — 計測 累積面積 9.738% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TETHER_SPRING_STIFFNESSTETHER_SPRING_DAMPINGTETHER_THICKNESS_BASE_PX
  function drawTether() {
    var or = origin.getBoundingClientRect();
    var ir = icon.getBoundingClientRect();
    var ox = or.left + or.width / 2, oy = or.top + or.height / 2;
    var ix = ir.left + ir.width / 2, iy = ir.top + ir.height / 2;
    var dx = ix - ox, dy = iy - oy;
    var length = Math.hypot(dx, dy);
    var angle = Math.atan2(dy, dx) * 180 / Math.PI;
    var thickness = Math.max(1.5, TETHER_THICKNESS_BASE_PX - length * 0.012);
    var boardRect = root.getBoundingClientRect();
    line.style.left = (ox - boardRect.left) + 'px';
    line.style.top = (oy - boardRect.top) + 'px';
    line.style.width = length + 'px';
    line.style.height = thickness + 'px';
    line.style.transform = 'rotate(' + angle + 'deg)';
    line.style.opacity = length < 2 ? '0' : '1';
  }

  function settleTick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min((ts - lastTs) / 1000, 0.032);
    lastTs = ts;
    var fx = (0 - pos.x) * TETHER_SPRING_STIFFNESS - vel.x * TETHER_SPRING_DAMPING;

02番は累積面積9.7%・平均強度39.6を記録し、動いたのは22/23フレームです。

03境界でつぶれるゴムのような伸び縮み

overflowOf() はポインターの座標がコンテナの端をどれだけ越えたかを測っていて、applyVisual() がその超過分を BOUNDARY_RUBBER_MAX_PX で頭打ちにした比率へ変換し、アイコンの拡大率を伸ばしつつ引かれている方向へ傾けます。端の外側で指を離すと BOUNDARY_SPRING_STIFFNESS と BOUNDARY_SPRING_DAMPING でアイコンを内側へ引き戻していて、伸びを決めた同じ二つの数値が戻りの跳ね方も決めています。

境界外に押すと伸びて戻るゴム境界
新しいタブで開く

03 · 境界外に押すと伸びて戻るゴム境界 — 計測 累積面積 10.521% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BOUNDARY_RUBBER_MAX_PXBOUNDARY_SPRING_STIFFNESSBOUNDARY_SPRING_DAMPING
  function overflowOf(clientX, clientY) {
    var b = bounds();
    var ox = 0, oy = 0;
    if (clientX < b.minX) ox = clientX - b.minX;
    else if (clientX > b.maxX) ox = clientX - b.maxX;
    if (clientY < b.minY) oy = clientY - b.minY;
    else if (clientY > b.maxY) oy = clientY - b.maxY;
    return { x: ox, y: oy };
  }

  function applyVisual(clampedX, clampedY, overflow) {
    var ratioX = Math.min(1, Math.abs(overflow.x) / BOUNDARY_RUBBER_MAX_PX);
    var ratioY = Math.min(1, Math.abs(overflow.y) / BOUNDARY_RUBBER_MAX_PX);
    var ratio = Math.max(ratioX, ratioY);
    var scaleStretch = 1 + ratio * 0.28;
    var scaleSquash = 1 - ratio * 0.22;
    var skewX = overflow.x !== 0 ? (overflow.x > 0 ? 1 : -1) * ratioX * 6 : 0;
    var skewY = overflow.y !== 0 ? (overflow.y > 0 ? 1 : -1) * ratioY * 6 : 0;
    var sx = ratioX >= ratioY ? scaleStretch : scaleSquash;
    var sy = ratioY >= ratioX ? scaleStretch : scaleSquash;
    icon.style.transform = 'translate(' + clampedX.toFixed(1) + 'px,' + clampedY.toFixed(1) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ') skew(' + skewX.toFixed(2) + 'deg,' + skewY.toFixed(2) + 'deg)';
    root.classList.toggle('is-stretch', ratio > 0.02);
  }

03番は累積面積10.5%・平均強度99.0を記録し、動いたのは20/23フレームです。

04中点を越えると隣と入れ替わるスワップ

moveDrag() はポインターが動くたびにドラッグ中のチップの画面上の中心を計算し直し、最も近いスロットへ丸めていて、そのスロットが現在のスロットと違う間は moveInOrder() が二つの位置を順序配列の中で入れ替え続けます。入れ替わるたびに retarget() と kickSpring() が呼ばれ、動かしていない他のチップを SWAP_FLIP_STIFFNESS と SWAP_FLIP_DAMPING で新しいスロットへ、瞬時にではなく運んでいきます。

隣の中間点を越えると入れ替わるスワップドラッグ
新しいタブで開く

04 · 隣の中間点を越えると入れ替わるスワップドラッグ — 計測 累積面積 22.487% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWAP_FLIP_STIFFNESSSWAP_FLIP_DAMPINGKEY_STEP_SLOTS
    var chipRect = dragChip.getBoundingClientRect();
    var trackRect = track.getBoundingClientRect();
    var centerX = chipRect.left + chipRect.width / 2 - trackRect.left;
    var targetSlot = Math.max(0, Math.min(chips.length - 1, Math.round((centerX - STEP / 2) / STEP)));
    while (targetSlot !== slotOf(DRAGGED_ID)) {
      var from = slotOf(DRAGGED_ID);
      var step = targetSlot > from ? 1 : -1;
      var to = from + step;
      moveInOrder(from, to);
      retarget();
      kickSpring();
      caption.textContent = '場所が入れ替わりました';
    }
    state[DRAGGED_ID].x = rawX;
  }

  function endDrag() {
    dragging = false;
    root.classList.remove('is-dragging');
    caption.textContent = '中点を越えると隣と場所を入れ替えます';

04番は累積面積22.5%・平均強度103.3を記録し、動いたのは17/23フレームです。

05ハブを中心に半径固定で回る軌道ハンドル

angleFromPointer() は Math.atan2() を使ってポインターの座標をハブの固定中心を軸にした一つの角度へ変換していて、applyHandle() は常にハンドルを Math.cos(angle) * ORBIT_RADIUS_PX とそれに対応するsin項の位置へ置くため、半径そのものは変わりません。ハンドルを離すとその角度の最後の変化率が inertiaTick() へ渡され、ORBIT_DECAY_RATE を毎フレーム角速度へ掛け続けて回転が収まるまで動かします。

中心を基準に角度だけ変わる軌道ハンドルドラッグ
新しいタブで開く

05 · 中心を基準に角度だけ変わる軌道ハンドルドラッグ — 計測 累積面積 11.255% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ORBIT_RADIUS_PXORBIT_DECAY_RATEORBIT_KEY_STEP_DEG

  function applyHandle() {
    var x = Math.cos(angle) * ORBIT_RADIUS_PX;
    var y = Math.sin(angle) * ORBIT_RADIUS_PX;
    handle.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
  }

  function angleFromPointer(clientX, clientY) {
    var hr = hub.getBoundingClientRect();
    var hx = hr.left + hr.width / 2, hy = hr.top + hr.height / 2;
    return Math.atan2(clientY - hy, clientX - hx);
  }

  function shortestDelta(a, b) {
    var d = b - a;
    while (d > Math.PI) d -= Math.PI * 2;
    while (d < -Math.PI) d += Math.PI * 2;
    return d;
  }

05番は累積面積11.3%・平均強度186.6を記録し、動いたのは23/23フレームです。

06壁で跳ね返る慣性フリング

flingTick() は毎フレーム速度ぶんだけボールの位置を進めていて、bounds() を越えた瞬間にその軸の速度成分を反転させ、そのフレームに is-bounce クラスを付けます。同じ関数が毎フレーム両方の速度成分に FLING_DECAY_RATE を掛けていくため、ボールは速度が1フレームあたり4pxを下回るまで滑って跳ね続け、そこで flingTick() が止めます。

離す速度が残りの距離を押す慣性フリングドラッグ
新しいタブで開く

06 · 離す速度が残りの距離を押す慣性フリングドラッグ — 計測 累積面積 9.251% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLING_DECAY_RATEKEY_IMPULSE_PX_SflingTick
  function flingTick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = Math.min((ts - lastTs) / 1000, 0.032);
    lastTs = ts;
    pos.x += vel.x * dt;
    pos.y += vel.y * dt;
    var b = bounds();
    var origin = ballOrigin();
    var cx = origin.x + pos.x, cy = origin.y + pos.y;
    if (cx < b.minX) { pos.x += (b.minX - cx); vel.x = Math.abs(vel.x); root.classList.add('is-bounce'); }
    else if (cx > b.maxX) { pos.x -= (cx - b.maxX); vel.x = -Math.abs(vel.x); root.classList.add('is-bounce'); }
    if (cy < b.minY) { pos.y += (b.minY - cy); vel.y = Math.abs(vel.y); root.classList.add('is-bounce'); }
    else if (cy > b.maxY) { pos.y -= (cy - b.maxY); vel.y = -Math.abs(vel.y); root.classList.add('is-bounce'); }
    vel.x *= Math.pow(FLING_DECAY_RATE, dt * 60);
    vel.y *= Math.pow(FLING_DECAY_RATE, dt * 60);
    applyBall();
    var speed = Math.hypot(vel.x, vel.y);
    if (speed > 4) { raf = requestAnimationFrame(flingTick); return; }
    vel.x = 0; vel.y = 0;
    root.classList.remove('is-bounce');

06番は累積面積9.3%・平均強度55.6を記録し、動いたのは17/23フレームです。

07ゴミ箱の上で震えてから消えるトラッシュゾーン

shakeTick() はアイコンがゴミ箱ゾーンに重なっている時間を dwellMs という一つの値にまとめていて、その時間を TRASH_DWELL_SHAKE_MS に対する比率へ変換してサインとコサインのシェイク幅を決め、上限は TRASH_SHAKE_MAX_PX です。dwellMs が TRASH_DELETE_DWELL_MS を超えると、今離せば削除されると警告するキャプションへ切り替わるため、同じタイマーが震えの大きさと「離せば削除」の判定の両方を担っています。

削除ゾーン上で震える警告を見せるトラッシュシェイク
新しいタブで開く

07 · 削除ゾーン上で震える警告を見せるトラッシュシェイク — 計測 累積面積 17.508% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRASH_DWELL_SHAKE_MSTRASH_SHAKE_MAX_PXTRASH_DELETE_DWELL_MS
  function shakeTick(ts) {
    if (deleted) return;
    var isOver = overlapsTrash();
    if (isOver && !overTrash) { dwellStart = ts; }
    overTrash = isOver;
    trash.classList.toggle('is-armed', overTrash);
    var shakeX = 0, shakeY = 0;
    var dwellMs = 0;
    if (overTrash && dwellStart !== null) {
      dwellMs = ts - dwellStart;
      var amp = Math.min(1, dwellMs / TRASH_DWELL_SHAKE_MS) * TRASH_SHAKE_MAX_PX;
      shakeX = Math.sin(ts / 40) * amp;
      shakeY = Math.cos(ts / 55) * amp * 0.6;
      if (dwellMs > TRASH_DELETE_DWELL_MS) {
        caption.textContent = '今離すと削除されます';
        root.classList.add('is-armed');
      } else {
        caption.textContent = 'さらにいると削除準備に入ります';
        root.classList.remove('is-armed');
      }
    } else {
      caption.textContent = dragging ? 'ゴミ箱の上へ動かしてみてください' : 'ゴミ箱の上に長くいると震えて消えます';
      root.classList.remove('is-armed');
    }
    applyIcon(shakeX, shakeY, 1);

07番は累積面積17.5%・平均強度115.5を記録し、動いたのは17/23フレームです。

08触れたものを拾い上げるラッソ選択

drawLasso() は毎フレーム、ドラッグの開始点から現在のポインター位置までの矩形を描き直していて、そのドラッグが LASSO_MIN_DRAG_PX を越えると updateLassoSelection() が各カードの実測境界矩形へ rectsIntersect() を毎回当てて選択状態を決めます。この領域自体に role="listbox" が付いているため、キーボード操作だけでもカードを一枚ずつフォーカスしてEnterで選択でき、矩形をドラッグする必要はありません。

引いた四角形の内側だけを選ぶ投げ縄セレクトドラッグ
新しいタブで開く

08 · 引いた四角形の内側だけを選ぶ投げ縄セレクトドラッグ — 計測 累積面積 14.549% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LASSO_MIN_DRAG_PXrectsIntersectrole="listbox"

  function rectsIntersect(a, b) {
    return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
  }

  function updateLassoSelection(rect) {
    items.forEach(function (el, idx) {
      var r = el.getBoundingClientRect();
      setSelected(idx, rectsIntersect(rect, r));
    });
  }

  function drawLasso(x0, y0, x1, y1) {
    var left = Math.min(x0, x1), top = Math.min(y0, y1);
    var w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
    var fieldRect = field.getBoundingClientRect();
    lasso.style.left = (left - fieldRect.left) + 'px';
    lasso.style.top = (top - fieldRect.top) + 'px';
    lasso.style.width = w + 'px';
    lasso.style.height = h + 'px';
    lasso.style.opacity = '1';

08番は累積面積14.5%・平均強度74.3を記録し、動いたのは17/23フレームです。

09磁石・ゴム・フリングを一つにまとめたヒーロー盤面

同じドラッグ処理はまず nearestCell() を HERO_GRID_MAGNET_SNAP_PX と照らし合わせていて、どのセルも近くなければ盤の端を越えた超過分を HERO_BOUNDARY_RUBBER_MAX_PX で頭打ちにしてアイコンの拡大率をつぶす方へ戻ります。磁石セルの外で指を離すと06番と同じフリングのループにアイコンが渡され、ここでは FLING_DECAY_RATE の代わりに HERO_FLING_DECAY_RATE で減衰するため、一つの盤が一つのポインター処理から三つの仕組みすべてを動かしています。

複数の物性を一つのボードに集めたドラッグヒーロー場面
新しいタブで開く

09 · 複数の物性を一つのボードに集めたドラッグヒーロー場面 — 計測 累積面積 8.088% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HERO_GRID_MAGNET_SNAP_PXHERO_BOUNDARY_RUBBER_MAX_PXHERO_FLING_DECAY_RATE
    var near = nearestCell(cx, cy);
    cells.forEach(function (c) { c.classList.remove('is-target'); });

    if (near && near.dist < HERO_GRID_MAGNET_SNAP_PX) {
      near.cell.classList.add('is-target');
      var pull = Math.max(0, 1 - near.dist / HERO_GRID_MAGNET_SNAP_PX) * 0.6;
      pos.x = rawX + ((near.x - o.x) - rawX) * pull;
      pos.y = rawY + ((near.y - o.y) - rawY) * pull;
      applyIcon(1, 1);
      caption.textContent = '磁石マスに吸着します';
    } else {
      var b = boardBounds();
      var overX = 0, overY = 0;
      if (cx < b.minX) overX = cx - b.minX; else if (cx > b.maxX) overX = cx - b.maxX;
      if (cy < b.minY) overY = cy - b.minY; else if (cy > b.maxY) overY = cy - b.maxY;
      if (overX !== 0 || overY !== 0) {
        var clampedX = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overX));
        var clampedY = Math.max(-HERO_BOUNDARY_RUBBER_MAX_PX, Math.min(HERO_BOUNDARY_RUBBER_MAX_PX, overY));
        pos.x = rawX - (overX - clampedX);
        pos.y = rawY - (overY - clampedY);
        var ratio = Math.max(Math.abs(clampedX), Math.abs(clampedY)) / HERO_BOUNDARY_RUBBER_MAX_PX;
        applyIcon(1 + ratio * 0.22, 1 - ratio * 0.18);
        caption.textContent = '境界でゴムのように押しつぶれます';

09番は累積面積8.1%・平均強度125.5を記録し、動いたのは18/23フレームです。

9パターンのうちどれがどのドラッグ操作に向くか

どんなドラッグ操作にも、掴んでから離すまでの間に何が起きるかを決める仕組みが要りますが、この9つのうちどれが向くかは実際に何を動かすかで変わります。01番のグリッド磁石は必ずどこか一つのマスに収まるべきアイコンに向いていて、ホーム画面のレイアウトのような場面に合います。03番のゴム境界は、どれだけ強く引かれても決まった枠の中にとどまるべきカードに向いています。04番の中点スワップは並び替えリストが必要とする形で、独自の挿入先インデックス計算を持つ姉妹編が ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種 にあります。07番の削除ゾーン滞在タイマーは配置ではなく削除を行うドロップ先の隣に置くべきもので、計算されたドロップゾーンのファイルアップロード版は ドラッグ アンド ドロップ ファイル アップロードUI — ドロップゾーン9種の計算 にあります。05番の固定半径の軌道は、平面ではなく円周上を動く値に最も近い形で、ダイヤル側から同じ考え方を扱っているのが css ゲージ 9選 — 計算で回る円形コントロール で、css ドラッグ 移動を探しているならこの9つのうちどれがそのまま当てはまるかを先に決めておく価値があります。

9つのドラッグ物性パターンを面積と強度で測る

累積面積は固定カメラの1フレームのうち実際に塗り直された割合を測定フレーム全体で平均したもので、強度はその塗り直しが起きた場所の平均画素差です — 以下の9つの多くが25%未満なのは、静止したカードの上で小さなアイコンやチップやボール一つだけが動いているからです。

| パターン | 累積面積 | 平均強度 | 動いたフレーム |

|---|---|---|---|

| 01 グリッド磁石スナップ | 14.1% | 177.0 | 20/23 フレーム |

| 02 弾性テザー | 9.7% | 39.6 | 22/23 フレーム |

| 03 境界ゴム押しつぶし | 10.5% | 99.0 | 20/23 フレーム |

| 04 隣接スワップ | 22.5% | 103.3 | 17/23 フレーム |

| 05 軌道ハンドル | 11.3% | 186.6 | 23/23 フレーム |

| 06 慣性フリング | 9.3% | 55.6 | 17/23 フレーム |

| 07 トラッシュゾーン シェイク | 17.5% | 115.5 | 17/23 フレーム |

| 08 ラッソ選択 | 14.5% | 74.3 | 17/23 フレーム |

| 09 ヒーロー盤面 | 8.1% | 125.5 | 18/23 フレーム |

9つのうち02・06・09番の三つは累積面積が10%を下回っていますが、これは欠陥ではなく想定どおりです — テザーの線一本、小さなボール一つ、盤の上のアイコン一つは、その下の物理計算がどう動いても固定カメラの1フレームをそう大きくは覆いません。

値が反応を変える場所 — 磁力スナップ距離・ゴムの最大伸び・減衰係数

決め手になる数値は主に三つです。GRID_MAGNET_SNAP_PX は01番のアイコンがセルの中心にどれだけ近づけば引き寄せが始まるかを決め、BOUNDARY_RUBBER_MAX_PX は03番のアイコンがコンテナの端をどこまで越えて伸びられるかの上限を決め、FLING_DECAY_RATE は06番の離した瞬間の速度が1フレームごとにどれだけ残るかを決めています。三つとも描かれる形そのもの — 磁力の吸着先・伸びたアイコン・跳ねるボール — は変えず、ポインターがどれだけ動けばその形が反応するか、そして指を離した後どれだけ長く動き続けるかだけを変えます。

どこで壊れるか — タッチスクロールとの衝突・境界外座標・キーボード代替経路

9つとも touch-action: none をどこかに指定していますが、範囲は同じではありません。8つは実際にドラッグされる要素一つ — アイコン・チップ・ハンドル・ボール — だけに絞っていて、カードのそれ以外の場所へのタッチ操作はページ自体のスクロールとして普通に働きます。08番だけは領域全体の .ls__field に指定していて、その領域内のどこからでもラッソが始まるため、どこも取りこぼさずページのスクロールへ渡さないようにしています。9つのうち6つ — 01・02・04・05・07・08番 — は bounds() や boardBounds() のような関数でポインター由来の位置を境界内へ収めておらず、指やポインターがデモカードの外へ出た後も動き続けると、離すまで何も引き戻さないままアイコンやチップやラッソの矩形が表示枠の外まで運ばれます。03・06・09番だけがその境界クランプを明示的に持っています。キーボード代替経路はそれとは無関係にどの番号でも同じ形で、ドラッグ対象の要素自身に張られた keydown がEnterで掴み、矢印キーで動かし、もう一度Enterで放すところまで担っていて、ポインターを失っても境界を越えてもインタラクション自体は失われません。9つを支える index.html・style.scss の組は、dqbvrvq6、自分の盤にも同じ境界クランプが足りているかを実際のコードで確かめる一番早い方法です。

変形3つとアクセシビリティ

| 変形 | 値の変化 | 感触 |

|---|---|---|

| 磁力グリッドのスナップ距離を調整する | 01 GRID_MAGNET_SNAP_PX 24 → 8 | 値を上げるとアイコンはセルから離れていても早めに引き寄せられて付き、値を下げるとセルの真上近くまで来て初めて吸着する。 |

| ゴム境界の最大伸びを調整する | 03 BOUNDARY_RUBBER_MAX_PX 40 → 90 | 値を下げると境界の外へ少し押しただけですぐ止まって跳ね返り、値を上げるとずっと長く伸びてから止まって跳ね返る。 |

| 慣性フリングの減衰係数を調整する | 06 FLING_DECAY_RATE 0.92 → 0.98 | 値を下げると指を離してから数フレームですぐ止まり、値を上げると離した速度のままずっと長く滑り続ける。 |

9つのうちどれも aria-live でアイコンがセルに吸着したことやカードがラッソ選択に加わったことを読み上げでは伝えません — マークアップ自体が持つ状態変化以上の案内はありません。9つとも同じ matchMedia('(prefers-reduced-motion: reduce)') で prefers-reduced-motion を確認していて、使い道は二つ共通しています。ドラッグする前に流れるアイドル状態のアトラクトループを飛ばすことと、物理演算そのものの内部でバネやフリングやシェイクを再生せずに静止位置へ直接移すことです。08番のラッソ選択領域だけが focus({ preventScroll: true }) も呼んでいて、選択リスト内のカードへTabで移動してもデモカードが画面外へスクロールされません。Pointer Events・requestAnimationFrame()・prefers-reduced-motion・transform はいずれもMDNに文書があります。

ドラッグ アンド ドロップ 実装 はライブラリなしでもできるか?

この9つはすべて、HTML5のドラッグ&ドロップAPIが使う dragstart・dragover・drop ではなく、Pointer Events API の pointerdown・pointermove・pointerup・pointercancel の上で動いています。この一つのイベント群だけでマウス・タッチ・ペンの入力がすでに揃うため、入力元を揃えるためにも、バネやフリングや磁石の計算をするためにも、9つのうちどれもcss ドラッグアンドドロップのライブラリを呼んでいません。上に書いた計算はすべて requestAnimationFrame() の中の普通の数値だけで動いていて、ドラッグ ui を新しく組むたびに、ドラッグ アンド ドロップ やり方を一から調べ直す必要はありません。

指を離す前に確認しておきたいFAQ

これはHTML5のドラッグ&ドロップAPIを使っていますか?

いいえ — 9つとも dragstart・dragover・drop ではなく、実際の要素に pointerdown・pointermove・pointerup/pointercancel を張っています。draggable="true" を持つ要素も DataTransfer を読む箇所も9つの中に一つもなく、同じコードがマウス・タッチ・ペンのどれでも動くのはそのためです。

9つとも一切ポインターなしで操作できますか?

はい — 9つとも同じドラッグ対象の要素に keydown を張っていて、Enterで掴み、矢印キーで動かし、もう一度Enterで放すという同じ手順に従っています。08番のラッソだけは role="listbox" を持つ領域がもう一つの経路を足していて、フォーカスしたカードをEnterで一枚ずつ選ぶこともできます。

9つのうちどれが最もフレームを塗り直す面積が小さいですか、その理由は?

09番のヒーロー場面が累積面積8.1%で最も小さく、06番のフリング(9.3%)・02番のテザー(9.7%)がそれに続きます。一つの小さなアイコンやボールだけを動かす方式はその周囲のピクセルしか塗り直しませんが、04番のスワップは行のチップ全部を同時に描き直すため22.5%まで上がります。

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

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