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

カート ドロワー UIパターン9選 — スプリングからバッジ慣性まで

9つのうち1つは開く位置の値が目標を少し超えてからばね曲線に沿って戻り、1つは引く距離の値が基準を超えると抵抗が強まり、もう1つは個数の値が変わるたびにアイコン上の数字が毎フレーム残りの差の一部だけ追いつきます。この9つがカート ドロワーの動きです。

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

9つのデモはすべてこのページの中だけで完結します。決済ボタンを押してもfetchも外部の決済APIも呼ばれず、商品名と価格はすべて架空のもので実在するブランドとは一切関係がありません。それでも9つとも訪問者の実際の操作に反応します——01と02はカートを開閉するボタンのクリック(ポインター操作)に反応し、03は行を並び替える操作、04は数量を増減するボタンのクリック、05はパネルをつかんで動かすポインターのドラッグ、06はカートへの追加・削除で個数の値が0をまたいだ瞬間、07はカート内リストのスクロール、08は削除ボタンのクリック、09は数量が変わった瞬間の個数の値に反応します。

9つのパターンの解剖 — 位置・距離・個数の値がいつ変わるか

9つは、位置・距離・個数のどの値をどう計算し直すかで3つに分かれます。この違いを飛ばすと、カート デザインの実装は開閉するだけのパネルで終わってしまいます。

決まった長さのイージング区間 — 02・06・08

02・06・08は状態が切り替わった瞬間にだけ計算が始まり、決まった時間(DURATION_SやDUR_MS)の中でイージング関数の進行値ひとつから高さ・不透明度・拡大率などの値を導きます。値は0から1まで一度だけ進み、次の状態切り替えが起きるまで計算は動きません。

経過時間や距離から直接解く減衰・抵抗の式 — 01・04・05

01・04・05は進行値を使わず、経過時間や現在のドラッグ距離をそのまま式に入れて値を求めます。01は減衰調和振動子の方程式(springProgress)を毎フレーム解いて位置の値を作り、目標を一度超えてから戻ります。04は値が変わった瞬間からの経過時間を減衰サイン波(elasticPulse)に入れて計算し、05はドラッグ距離そのものを指数関数の抵抗式(rubberband)に通します。

動き続ける目標を追いかける値 — 03・07・09

03・07・09には完了という状態がなく、動き続ける目標値を毎フレーム追いかけ続けます。03は並び順が変わるたびに位置の値から遅延を求め直し(computeDelay)、07はスクロール位置を目標にlerpで影と高さの値を追いかけ、09は個数の目標値をlerpで追いかけながらバッジの数字を更新します。

パターン別コード

01スライドオーバーシュート — 開いた位置の値が目標を少し超えてから戻るカートパネル

パネルが開いた状態になると、位置の値が目標地点を少し超えてから戻るばね曲線に沿って収まり、閉じた状態になると同じ曲線を逆方向にたどって値が減っていきます。

スライドオーバーシュート — 開いた位置の値が目標を少し超えてから戻るカートパネル
新しいタブで開く

01 · スライドオーバーシュート — 開いた位置の値が目標を少し超えてから戻るカートパネル — 計測 累積面積 100.0% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ZETAOMEGAspringProgress

数字が示すもの

23フレーム中19フレームで動き、累積面積100.00%・平均強度165.3を記録しています。ZETAを0.42、OMEGAを9.5とした減衰調和振動子の式をspringProgressが毎フレーム解き、この位置の値を一度だけ大きく超えさせてから収めています。

  // 計算層: 減衰調和振動子(ばね)の方程式を毎フレーム直接解いて位置の値を作る — イージング曲線を
  // 借りるのではなく、減衰比(ZETA)・固有角振動数(OMEGA) で目標を一度超えてから(オーバーシュート)戻る値を計算する。
  var ZETA = 0.42;
  var OMEGA = 9.5;
  var OMEGA_D = OMEGA * Math.sqrt(1 - ZETA * ZETA);
  function springProgress(tSec) {
    if (tSec <= 0) { return 0; }
    var env = Math.exp(-ZETA * OMEGA * tSec);
    return 1 - env * (Math.cos(OMEGA_D * tSec) + (ZETA * OMEGA / OMEGA_D) * Math.sin(OMEGA_D * tSec));
  }

  var DURATION_S = 0.85;
  var isOpen = false;
  var raf = null;

  function apply(v) {
    var pct = (1 - v) * 100;
    panel.style.transform = 'translateX(' + pct.toFixed(2) + '%)';
    backdrop.style.opacity = Math.min(1, Math.max(0, v)).toFixed(3);
  }

02背景縮小 — パネルが開いた分だけ背後の画面の大きさの値が縮む奥行き表現

パネルの開いた進行度の値が大きくなるほど、背後の画面の大きさの値と暗さの値が同じ割合で縮んでパネルが手前に浮いたように見え、閉じる進行度ではその2つの値が同じ割合で戻ります。

背景縮小 — パネルが開いた分だけ背後の画面の大きさの値が縮む奥行き表現
新しいタブで開く

02 · 背景縮小 — パネルが開いた分だけ背後の画面の大きさの値が縮む奥行き表現 — 計測 累積面積 55.719% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MIN_SCALEMAX_DIMeaseInOutCubic

数字が示すもの

23フレーム中15フレームで動き、累積面積55.72%・平均強度133.1を記録しています。MIN_SCALE(0.86)とMAX_DIM(0.5)が縮小と暗さの上限を決め、easeInOutCubicで求めたひとつの進行値からその両方を導いています。

  // 計算層: パネルの開き進行値(progress, 0〜1)をイージング関数で補間し、背後の画面の縮小値と
  // 暗さの値を同じ比率で計算する — scale・filter を別々にアニメーションせず、一つの値から両方を導く。
  function easeInOutCubic(p) {
    return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
  }

  var DURATION_S = 0.5;
  var MIN_SCALE = 0.86;
  var MAX_DIM = 0.5;
  var isOpen = false;
  var raf = null;

  function apply(progress) {
    var eased = easeInOutCubic(Math.min(1, Math.max(0, progress)));
    var scale = 1 - eased * (1 - MIN_SCALE);
    var dim = eased * MAX_DIM;
    page.style.transform = 'scale(' + scale.toFixed(4) + ')';
    page.style.filter = 'brightness(' + (1 - dim).toFixed(3) + ')';
    panel.style.transform = 'translateX(' + ((1 - eased) * 100).toFixed(2) + '%)';
  }

03項目段階表示 — リスト内の順番の値だけ遅延が伸びるカートの行

パネルが開いた状態になると、各行の登場時点がリスト内の順番の値に比例して遅れる遅延の値を受け取り上から順に現れ、項目が並び替えられるとその遅延の値も新しい順番に合わせて求め直されます。

項目段階表示 — リスト内の順番の値だけ遅延が伸びるカートの行
新しいタブで開く

03 · 項目段階表示 — リスト内の順番の値だけ遅延が伸びるカートの行 — 計測 累積面積 29.915% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

computeDelayTOTAL_SPREAD_MSBASE_MS

数字が示すもの

23フレーム中10フレームで動き、累積面積29.92%・平均強度96.6を記録しています。computeDelayがTOTAL_SPREAD_MS(480ms)を項目数で割ったperStepに順番の値を掛けて各行の遅延を求め直すため、並び替え後も同じ規則で段階表示が崩れません。

  var NAMES = ['中綿ベスト', 'ニット手袋', 'チェックマフラー', '防寒ブーツ'];

  // 計算層: 合計登場時間(TOTAL_SPREAD_MS)を現在の項目数で割り、項目ごとに遅延の値を求め直す
  // (computeDelay()) — 順番が変わっても同じ規則で各位置の遅延の値が再計算される。
  var BASE_MS = 40;
  var TOTAL_SPREAD_MS = 480;

  function computeDelay(indexInOrder, total) {
    var steps = Math.max(1, total - 1);
    var perStep = TOTAL_SPREAD_MS / steps;
    return BASE_MS + indexInOrder * perStep;
  }

  var order = NAMES.map(function (_, i) { return i; });

  function render() {
    list.textContent = '';
    order.forEach(function (nameIndex, pos) {
      var li = document.createElement('li');
      li.className = 'cdis__row';
      li.style.setProperty('--delay', computeDelay(pos, order.length) + 'ms');
      li.textContent = NAMES[nameIndex];
      list.appendChild(li);
    });
  }

04数量の弾性バンプ — 変化量の値だけ大きくなってから戻る数量の数字

数量の値が変わった状態になると、その数字の大きさの値が変化量に比例して大きくなってから弾性曲線に沿って元の大きさに戻り、値が減った状態では逆方向に少し小さい値を経て戻ります。

数量の弾性バンプ — 変化量の値だけ大きくなってから戻る数量の数字
新しいタブで開く

04 · 数量の弾性バンプ — 変化量の値だけ大きくなってから戻る数量の数字 — 計測 累積面積 8.746% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

elasticPulseAMPLITUDEapplyRipple

数字が示すもの

23フレーム中16フレームで動き、累積面積8.75%・平均強度45.1を記録しています。elasticPulseが値変更からの経過時間をFREQ18・DECAY9の減衰サイン波に入れて数字の大きさを計算し、別関数のapplyRippleが同じ変更時刻を基準に周囲のにじみの半径と濃さをeaseOutCubicで導いています。

  // 計算層: 値が変わった瞬間からの経過時間(t)を減衰サイン波(elasticPulse())に毎フレーム入れて
  // scale に代入する — CSS keyframes の固定パーセント曲線ではなく、値変更の時刻を基準に解き直す。
  var AMPLITUDE = 0.3;
  var FREQ = 18;
  var DECAY = 9;
  function elasticPulse(tSec) {
    if (tSec <= 0) { return 1; }
    return 1 + AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
  }

  // 計算層2: バンプとは別に、バッジ周りのにじみ(ripple)は ease-out 曲線で半径を広げながら
  // 同時に薄くなるよう計算する — 一つの進行値(p)から scale・opacity の両方を導く(depth-scale 項目と同じパターン、対象が違うだけ)。
  function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }
  var rippleEl = document.getElementById('cqbeRipple');
  function applyRipple(tSec) {
    var p = Math.min(1, Math.max(0, tSec / 0.5));
    var eased = easeOutCubic(p);
    var s = 0.4 + eased * 2.1;
    var op = (1 - eased) * 0.4;
    rippleEl.style.transform = 'translate(-50%, -50%) scale(' + s.toFixed(3) + ')';
    rippleEl.style.opacity = op.toFixed(3);
  }

05ラバーバンドドラッグ — 引く距離の値が限界を超えると抵抗が強まる閉じ方

パネルを引く距離の値が増えるほど、追従する移動の値の比率が徐々に減る抵抗曲線に従い、その距離の値が基準を超えた状態になるとパネルが閉じる位置まで残りの距離の値が一度に縮みます。

ラバーバンドドラッグ — 引く距離の値が限界を超えると抵抗が強まる閉じ方
新しいタブで開く

05 · ラバーバンドドラッグ — 引く距離の値が限界を超えると抵抗が強まる閉じ方 — 計測 累積面積 8.522% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rubberbandTHRESHOLDMAX_PULL

数字が示すもの

23フレーム中16フレームで動き、累積面積8.52%・平均強度161.6を記録しています。THRESHOLD(90px)を超えた分だけrubberbandが指数関数で圧縮し、指を離した時点の距離の値がTHRESHOLDの1.3倍を超えていればdismissがそのまま420pxの位置まで320msでパネルを閉じ切ります。

  // 計算層: ドラッグ距離が閾値を超えると抵抗関数(rubberband())で移動量を圧縮する —
  // 引くほど画面に反映される比率が減る本物のラバーバンド式(iOS のオーバースクロールと同系統)で、
  // 単純な clamp ではない。
  var THRESHOLD = 90;
  var MAX_PULL = 140;
  function rubberband(dx) {
    var sign = dx < 0 ? -1 : 1;
    var abs = Math.abs(dx);
    if (abs <= THRESHOLD) { return dx; }
    var over = abs - THRESHOLD;
    var compressed = MAX_PULL * (1 - Math.exp(-over / MAX_PULL));
    return sign * (THRESHOLD + compressed);
  }

  var dragging = false;
  var startX = 0;
  var current = 0;

  function apply(dx) {
    var x = rubberband(dx);
    card.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
    var trashOpacity = Math.min(1, Math.abs(x) / THRESHOLD);
    row.style.setProperty('--trash-opacity', trashOpacity.toFixed(3));
  }

06空状態のクロスフェード — 項目数の値が0から変わる瞬間に入れ替わる案内文

項目数の値が0の状態では案内文の濃さの値がそのまま保たれ、その値が1以上に変わった状態になると案内文の濃さの値は下がり、リストの濃さの値は上がる交差する値で入れ替わります。

空状態のクロスフェード — 項目数の値が0から変わる瞬間に入れ替わる案内文
新しいタブで開く

06 · 空状態のクロスフェード — 項目数の値が0から変わる瞬間に入れ替わる案内文 — 計測 累積面積 14.408% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

crossfadeApplyDUR_MSframe

数字が示すもの

23フレーム中8フレームで動き、累積面積14.41%・平均強度74.6を記録しています。crossfadeApplyがひとつの進行値pからitemsOpacityとその逆数のemptyOpacityを同時に導き、DUR_MS(420ms)の間だけeaseInOutCubicで計算し直しています。

  // 計算層: 一つの進行値(p, 0〜1)をイージングし、二つの状態の opacity を互いに逆方向へ同時に
  // 計算する(crossfadeApply()) — 二つのアニメーションを別々にかけず、一つの値から相補的に導く。
  function easeInOutCubic(p) {
    return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
  }
  var DUR_MS = 420;
  var hasItems = false;
  var raf = null;

  function crossfadeApply(p, toItems) {
    var eased = easeInOutCubic(p);
    var itemsOpacity = toItems ? eased : 1 - eased;
    var emptyOpacity = 1 - itemsOpacity;
    listEl.style.opacity = itemsOpacity.toFixed(3);
    listEl.style.transform = 'scale(' + (0.96 + itemsOpacity * 0.04).toFixed(3) + ')';
    emptyEl.style.opacity = emptyOpacity.toFixed(3);
    emptyEl.style.transform = 'scale(' + (0.96 + emptyOpacity * 0.04).toFixed(3) + ')';
  }

07決済バーの持ち上げ — スクロール位置の値に応じて影と高さの値が変わる下部バー

リストのスクロール位置の値が下端に近づいた状態になると、下部バーの影の濃さの値と高さの値がともに増えて手前に持ち上がり、スクロール位置の値が再び上に移ると両方の値が元の大きさに戻ります。

決済バーの持ち上げ — スクロール位置の値に応じて影と高さの値が変わる下部バー
新しいタブで開く

07 · 決済バーの持ち上げ — スクロール位置の値に応じて影と高さの値が変わる下部バー — 計測 累積面積 8.047% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lerpCYCLE_MScomputeMax

数字が示すもの

23フレーム中23フレームで動き、累積面積8.05%・平均強度110.1を記録しています。tickがscrollTopをmaxScrollで割った目標値へlerpで0.18ずつ毎フレーム近づけるため、スクロールが止まった後も影と高さの値がわずかに遅れて追いつきます。

  // 計算層: スクロール進行率(0〜1)を毎フレーム lerp() で影の濃さ・bar の高さの値に補間する —
  // スクロール位置をそのまま使わず、目標値へ毎フレーム少しずつ近づく慣性補間である。
  function lerp(a, b, t) { return a + (b - a) * t; }

  var maxScroll = 1;
  function computeMax() {
    maxScroll = Math.max(1, scrollEl.scrollHeight - scrollEl.clientHeight);
  }

  var shadow = 0;
  var raf = null;
  function tick() {
    var target = Math.min(1, scrollEl.scrollTop / maxScroll);
    shadow = lerp(shadow, target, 0.18);
    bar.style.setProperty('--shadow-op', shadow.toFixed(3));
    bar.style.transform = 'translateY(' + (-shadow * 2).toFixed(2) + 'px)';
    if (Math.abs(target - shadow) > 0.002) {
      raf = requestAnimationFrame(tick);
    } else {
      raf = null;
    }
  }

08削除の高さ折りたたみ — 消える行の高さの値が0になり下の行が詰める余白

行が削除された状態になると、その行の高さの値が0になるまでの間、下の行の位置の値も同じ速さで詰めて空いた場所を埋め、値が0に達した状態でその行はリストから消えます。

削除の高さ折りたたみ — 消える行の高さの値が0になり下の行が詰める余白
新しいタブで開く

08 · 削除の高さ折りたたみ — 消える行の高さの値が0になり下の行が詰める余白 — 計測 累積面積 26.947% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

collapseRoweaseInCubicSTOCK

数字が示すもの

23フレーム中8フレームで動き、累積面積26.95%・平均強度47.6を記録しています。collapseRowがeaseInCubicの進行値から高さ・不透明度・横方向のずれの3つの値を同時に計算し、行が0になった直後にrefillIfEmptyがカートの行数を数えて2行未満なら補充します。

  function collapseRow(row) {
    var h = row.getBoundingClientRect().height;
    row.style.overflow = 'hidden';
    row.style.height = h + 'px';
    row.style.willChange = 'height, opacity, transform';
    var start = performance.now();
    function frame(now) {
      var p = Math.min(1, (now - start) / DUR_MS);
      var eased = easeInCubic(p);
      row.style.height = (h * (1 - eased)) + 'px';
      row.style.opacity = String(1 - eased);
      row.style.transform = 'translateX(' + (-eased * 24) + 'px)';
      if (p < 1) {
        requestAnimationFrame(frame);
      } else {
        row.remove();
        refillIfEmpty();
      }
    }
    requestAnimationFrame(frame);
  }

09バッジ数の慣性 — 変わった値へ少しずつ追いつくアイコン上の数字

入っている個数の値が変わった状態になると、アイコン上の数字は一度に値へ届かず、毎フレーム残りの差の値の一部だけ追いつく慣性の値を経て到達し、その間数字の大きさの値も一瞬大きくなってから縮みます。

バッジ数の慣性 — 変わった値へ少しずつ追いつくアイコン上の数字
新しいタブで開く

09 · バッジ数の慣性 — 変わった値へ少しずつ追いつくアイコン上の数字 — 計測 累積面積 0.232% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lerptargetdisplayed

数字が示すもの

23フレーム中22フレームで動き、累積面積0.23%・平均強度70.4を記録しています。tickがdisplayedをtargetへlerpで0.16ずつ毎フレーム近づけ、二つの値の差が0.03を切った時点でdisplayedをtargetに合わせて止まります。

  // 計算層: 表示中の数字(displayed)が目標の数字(target)へ毎フレーム慣性 lerp() で
  // 近づいていく — 値が変わるたびに数え直すのではなく、二つの値の差を毎フレーム少しずつ減らす。
  function lerp(a, b, t) { return a + (b - a) * t; }

  var target = 0;
  var displayed = 0;
  var raf = null;

  function tick() {
    displayed = lerp(displayed, target, 0.16);
    var shown = Math.round(displayed);
    badge.textContent = String(shown);
    var pulse = 1 + Math.min(0.3, Math.abs(target - displayed) / (target + 1) * 0.6);
    badge.style.transform = 'scale(' + pulse.toFixed(3) + ')';
    if (Math.abs(target - displayed) > 0.03) {
      raf = requestAnimationFrame(tick);
    } else {
      displayed = target;
      badge.textContent = String(target);
      badge.style.transform = 'scale(1)';
      raf = null;
    }
  }

値がパターンを変える場所 — オーバーシュート係数・抵抗曲線・遅延の値

01のZETAが小さいほどオーバーシュートが目立つ理由

ZETAは減衰比の値で、1に近いほど振動を素早く抑え、0に近いほど目標を超えてから戻るまでの揺れが長く残ります。0.42という値は一度だけはっきり超えてから収まる程度に選ばれていて、揺れの回数そのものをspringProgressが式で決めています。

05のTHRESHOLDが抵抗の始まる位置の値を決める理由

THRESHOLD(90px)までは指の動きがそのままパネルの位置の値になり、そこを超えた分だけrubberbandの指数関数が効き始めて値の増え方が緩やかになります。THRESHOLDを大きくすると素直に動く区間が伸び、抵抗を感じ始める位置の値そのものが後ろにずれます。

03の遅延がリスト内の位置の値から直接求まる理由

computeDelayは絶対時間を決め打ちにせず、その行の順番の値(indexInOrder)と現在の項目数(total)からperStepを割り出して遅延を計算します。項目数が変われば同じ順番でもperStepの値が変わるため、行を増減しても段階表示全体の長さの値はTOTAL_SPREAD_MSのまま保たれます。

どこで壊れるか — 高速ドラッグ・数量急増・空のカート

この9つとcss ドロワー メニュー スライド9選 — JS最小・CSSだけ・決済画面 デザイン 9選 — カートから支払いまでコピペOK・絞り込み ui9選 — パネルスライドからチップ削除までとの違いは、どの値を計算し直しているかです。ドロワーメニューの回は開閉の進行値をイージング曲線だけで求め、スプリングの超過やドラッグの抵抗式は扱いません。決済画面の回は入力欄の検証状態の値を求め直すことが中心で、パネルの位置そのものは計算しません。絞り込みUIの回はチップの数の値からレイアウトの折り返しを求め直しますが、個数の値がバッジの数字を追いかける慣性計算は登場しません。この9つは位置・距離・個数という三つの値を、スプリングの方程式・ラバーバンドの抵抗式・lerpの慣性で求め直しています。

素早いドラッグと合わせて起きる05の閉じ判定

05のonUp()は指を離した時点の距離の値だけを見て閉じるかどうかを決めます。Math.abs(current) > THRESHOLD * 1.3が真になった時だけdismissが呼ばれて420pxの位置まで320msでパネルが閉じ切り、指を動かした速さの値そのものは判定に入っていません。ゆっくり長く引いても、勢いよく短く弾いても、離れた瞬間の距離の値が同じなら結果は同じです。gnavhgubを開くと、この9つの計算部分がすべてそのままコピーして使える形で入っています。

個数が9を超えた瞬間に起きる09のリセット

09の増加処理にはif (target > 9) { target = 0; displayed = 0; badge.textContent = '0'; }という分岐があり、個数の目標値が9を超えた状態になると増え続けるのではなく0へ戻ります。表示の数字も同じ行で0にそろえられるため、lerpによるなめらかな追いつきは働かず、バッジの数字は9の次に0へ飛びます。

08の詰め直しと06の空状態は同じ0にたどり着かない

08のrefillIfEmpty()は行の数が2未満になった状態を検知すると新しい行を追加し、リストの要素数が0になる手前で補充してしまいます。06のcrossfadeApplyが案内文へ切り替わるのは項目数の値が実際に0になった状態だけなので、08の画面だけを見ている限りその案内文が現れる瞬間には出会いません。空の状態を試すなら06のデモで項目を全部取り除く必要があります。

3つのバリエーションとアクセシビリティ

変更点 値 感触の違い
スプリング減衰比 01 ZETA 0.42 → 0.22 ZETAが0.42だと目標を少し超えてから一度大きく跳ねて収まりますが、0.22まで下げると減衰が弱まり目標をまたぐ振動があと2〜3回はっきり続いてばね特有の弾む感触が強くなります
ラバーバンド抵抗の閾値 05 THRESHOLD 90px → 150px THRESHOLDが90pxだと少し引いただけで閉じる位置まで値が飛んでしまい抵抗を感じる間がありませんが、150pxまで上げると抵抗が強まる区間が伸びて指先に返ってくる感触とリリース後に戻る弾力がはっきりします
削除の折りたたみ時間 08 DUR_MS 320ms → 620ms DUR_MSが320msだと消えた行の高さがすぐ縮んで下の行が一瞬で詰めるように見えますが、620msまで伸ばすと高さが縮む間に下の行が同じ速さで追いついてくる過程を目で追えるくらいゆっくり見えます

このzipには0.42・90px・320msの値が入っています。

操作を減らす設定と読み上げ利用者にも同じ計算値が届く理由

9つすべてがprefers-reduced-motionをJavaScriptのmatchMediaとCSSのメディアクエリの両方で確認しており、この設定を有効にした利用者には同じ位置・距離・個数の値がアニメーションなしで最終状態のまま届きます。装飾のためだけのアイコン——01の閉じるアイコン、05のごみ箱アイコン、06の案内アイコン、09のバッジアイコン——にはaria-hiddenが付いており、読み上げソフトはそれらを読み上げの対象から外します。一方で実際に操作するボタン——01の閉じるボタン、04の数量増減ボタン、09の追加ボタン、08の削除ボタン——にはaria-labelが付き、ラベルのないアイコンだけのボタンにはなっていません。

FAQ

カート ドロワー と はどんな計算を指しますか

位置・距離・個数という3つの値を、スプリングの方程式・ラバーバンドの抵抗式・lerpの慣性といった計算式でその都度求め直す9つのパターンのことです。ボタンを押した瞬間に固定のCSSアニメーションを1回再生するだけの実装とは異なり、値は毎フレーム式に入れ直されます。

この9つのうちどれかは本物の決済や在庫のAPIと通信していますか

どれも通信していません。決済ボタンはfetchも外部の決済APIも呼ばず、商品名・価格・在庫の数字はすべて架空のもので、実在するブランドとは関係がありません。

これはカート ui デザインの例に過ぎず、そのまま製品には使えませんか

例として眺めるだけでなく、そのまま使うことを前提に作られています。上に載せた9つの計算部分はどれも25行以内で外部ライブラリを使わず、差し替えが必要なのは商品データを取ってくる層とチェックアウトの送信先だけです。

Sources: MDN — easing-function · MDN — Pointer events · MDN — Window.requestAnimationFrame · MDN — ARIA dialog role · MDN — prefers-reduced-motion

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

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