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

ボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通り

9つのうち1つは押し込みの値が目標値を超えてから戻ることを繰り返して落ち着き、1つは落下した位置の値が床の値ではね返ることを繰り返して止まり、もう1つは離した位置の値が最も近い固定地点の値に引き寄せられて弾むように収まります。ボタン 押し込みのUIパターンを9通り、実測した数値とあわせて並べていきます。

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

9つとも本物のポインタ入力にだけ反応し、それ以外では動きません。5つ(02・04・05・07・09)はドラッグを追いかけ、2つ(01・06)は押し続ける操作を追いかけ、1つ(03)はクリックに反応し、1つ(08)は指を離した瞬間だけに反応します — 自動再生だけのデモはゼロです。9つのどれもMath.randomで動きを決めておらず、すべて固定の定数を読む決定的なrequestAnimationFrameループから数値を計算しているので、同じ入力を繰り返せば毎回まったく同じ数値になります。ここまで揺るがないボタン クリック アニメーションの土台があるからこそ、下の数値がそのまま再現できます。

9つのパターンの解剖 — どの値がいつ変わるか

9個のボタンが、同じ問いに9通りの答えを出しています。値が動き始めたあと、いつ・どうやって止まるかを何が決めているのか、という問いです。

5つのパターンが毎フレーム「加速度→速度→位置」を積み上げる理由

01・03・05・06・09は、tick()の中で同じ2行の型を回しています — 速度値に加速度値を積み上げ、その速度値を位置値に積み上げる、という型です。変わるのは加速度の求め方だけで、01・06・09はばね・減衰の式で目標値へ引き戻す加速度を計算し、05は振り子の復元トルクから計算し、03はその引き戻しの代わりに一定の下向き加速度を使い、位置値が床の値を超えるたびに跳ね返りを加えます。

2つのパターンが復元力を計算せず、毎フレーム一定の割合だけ値を動かす理由

02と07は加速度値にまったく触れません。02は目標位置の値と自分の位置値との差を、毎フレーム一定の割合だけ縮めます。07はそれとは逆に、離した瞬間の速度値を毎フレーム一定の割合だけ縮め、その縮んでいく速度が位置を運びます。どちらも質量値から力を計算する場面は一度もありません。

2つのパターンが積分せず、閉じた式から直接値を読む理由

04の引っぱり中と08の揺れは、フレームをまたいで何かを積み上げることが一切ありません。04はポインタの距離値を固定の指数曲線に通して伸び値を出し、08は経過時間をそのまま減衰する正弦波の式に代入して角度値を出します。どちらも毎フレーム値をゼロから計算し直しています(04は指を離した瞬間だけ、01たちと同じばねの型に切り替わり、伸び値を1へ戻します)。

パターン別コード

01押し込み値のオーバーシュート — 押した値が目標値を通り過ぎては戻ることを繰り返す

ボタンを押すと、押し込みの深さ値が目標の深さ値を何度か通り過ぎては戻り、その揺れ幅は回を追うごとに小さくなって最後は平らに収まります。フォームの送信ボタンや確認ダイアログの主要アクションにこの動きを使うと、小さな跳ね返りが「押した」ことを知らせつつ、反応の速さは損なわれません。

押し込み値オーバーシュート — 押し込みの値が目標値を超えて戻ることを繰り返し、幅が小さくなっていく方式
新しいタブで開く

01 · 押し込み値オーバーシュート — 押し込みの値が目標値を超えて戻ることを繰り返し、幅が小さくなっていく方式 — 計測 累積面積 7.131% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

KCPRESS_DEPTH

実測値が語ること

測定した23フレームのうち01は23フレームすべてで値が動き、累積面積は7.13%、平均強度は136.0でした。アンダーダンプなばねが絶えず往復を続けるため、ほぼ全フレームで動きが途切れません。

  // 計算レイヤー: ばね・減衰の式 accel = -(K/MASS)*(depth-target) - (C/MASS)*vel。
  // 目標値を通り過ぎて戻る動きは、減衰比(zeta = C/(2*sqrt(K*MASS)))が1未満(アンダーダンプ)の
  // ときに起こります — zetaが小さいほど揺れ幅は大きくなります。
  var K = 300, C = 7, MASS = 1, PRESS_DEPTH = 14;
  var depth = 0, vel = 0, target = 0, last = 0, raf = null;

  function paint() {
    var scale = 1 - depth / 340;
    btn.style.transform = 'translateY(' + depth.toFixed(2) + 'px) scale(' + scale.toFixed(4) + ')';
    val.textContent = depth.toFixed(1) + 'px';
  }
  function tick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    var accel = (-K * (depth - target) - C * vel) / MASS;
    vel += accel * dt;
    depth += vel * dt;
    paint();
    raf = requestAnimationFrame(tick);
  }

02追従する位置値の補間 — ボタンの位置値がポインタの位置値との差を一定の割合で縮めながら追いかける

浮動ボタンをドラッグすると目標の位置値は即座に動きますが、ボタン自身の位置値はその差を毎フレーム一定の割合でしか縮めないので、目に見えて後を追う形になります。ドラッグできるフローティングアクションボタンや自由に動かせるツールバーボタンでは、この遅れが「重さ」を感じさせ、ポインタに貼りついた印象を避けられます。

追従値の遅延補間 — ポインター位置の値をボタン位置の値が一定の比率ずつ追いかけて縮める方式
新しいタブで開く

02 · 追従値の遅延補間 — ポインター位置の値をボタン位置の値が一定の比率ずつ追いかけて縮める方式 — 計測 累積面積 10.234% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LERP_RATIOFAB_RtargetX

実測値が語ること

同じ23フレームの測定で02は23フレームすべてで動き、累積面積10.23%・平均強度156.8でした。ポインタが止まっている瞬間にも追いかけが続くため、動きが絶えません。

  // 計算レイヤー: 毎フレーム、ポインタの位置値(targetX/Y)とボタンの位置値(x/y)の差を
  // LERP_RATIOの割合だけ縮めます — x += (targetX-x)*LERP_RATIO。
  // 1に近いほどすぐ追いつき、小さいほど尾を引くように遅れます。
  var LERP_RATIO = 0.14;
  var FAB_R = 26;
  var x = 0, y = 0, targetX = 0, targetY = 0, raf = null;

  function bounds() { return { w: field.clientWidth, h: field.clientHeight }; }
  function clampPos(px, py) {
    var b = bounds();
    return [Math.max(FAB_R, Math.min(b.w - FAB_R, px)), Math.max(FAB_R, Math.min(b.h - FAB_R, py))];
  }
  function paint() { fab.style.transform = 'translate(' + (x - FAB_R).toFixed(1) + 'px,' + (y - FAB_R).toFixed(1) + 'px)'; }
  function tick() {
    x += (targetX - x) * LERP_RATIO;
    y += (targetY - y) * LERP_RATIO;
    paint();
    raf = requestAnimationFrame(tick);
  }

03落下と着地の跳ね返り — 位置値が落下し、床の値で何度も跳ね返ってから静止する

落下を起こすと、位置値は一定の加速度のもとで床の値に届くまで落ち続け、跳ね返ってはまた落ち、跳ねる高さを毎回縮めながら最後は静止します。画面に現れるバッジや目を引くボタンにこの落下・着地の動きを使うと、無限に繰り返すことなく最初の一瞥を引きつけられます。

落下値反発定着 — 位置の値が下に落ちて床の値ではね返ることを繰り返し止まる方式
新しいタブで開く

03 · 落下値反発定着 — 位置の値が下に落ちて床の値ではね返ることを繰り返し止まる方式 — 計測 累積面積 11.576% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRAVITYRESTITUTIONSTOP_VEL

実測値が語ること

03は測定した23フレームのうち17フレームで動き、累積面積11.58%・平均強度102.3でした。フレーム数が少ないのは、跳ねと跳ねの間、チップが弧の頂点で止まっている瞬間があるためです。

  // 計算レイヤー: vel += GRAVITY*dt、y += vel*dt で位置値を床の値まで落とし、
  // 床に着いたらvelを-RESTITUTION倍にして跳ね返らせます。
  // |vel|がSTOP_VEL未満になったら静止します。
  var GRAVITY = 2200, RESTITUTION = 0.52, STOP_VEL = 40, CHIP_H = 34;
  var y = 0, vel = 0, last = 0, raf = null, running = false;

  function floorY() { return field.clientHeight - CHIP_H; }
  function paint() { chip.style.transform = 'translateY(' + y.toFixed(1) + 'px)'; }
  function tick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    vel += GRAVITY * dt;
    y += vel * dt;
    var floor = floorY();
    if (y >= floor) {
      y = floor;
      vel = -vel * RESTITUTION;
      if (Math.abs(vel) < STOP_VEL) { vel = 0; paint(); running = false; return; }
    }
    paint();
    raf = requestAnimationFrame(tick);
  }

04引っぱり距離による伸び — ポインタの距離値が伸びるほどボタンの形の値が伸び、離すと戻る

ボタンから離れるようにドラッグすると、ポインタの距離値が曲線に通されてボタンの形の値をどんどん伸ばしますが、その伸び方は際限なく増えるのではなく徐々に鈍くなります。離すとばねが形の値を元の値まで引き戻します。リストの一番上にあるプルリフレッシュや、決済のスライド確認ボタンは、この抵抗感を使って「どこまで引けば十分か」を伝えます。

引っ張り距離値伸び — ポインターが離れた距離の値の分だけボタンの形の値が伸び、離すと元に戻る方式
新しいタブで開く

04 · 引っ張り距離値伸び — ポインターが離れた距離の値の分だけボタンの形の値が伸び、離すと元に戻る方式 — 計測 累積面積 3.297% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PULL_RESISTANCEMAX_STRETCHstretchFromDist

実測値が語ること

04は23フレームのうち19フレームで動き、累積面積は9つの中で最小の3.30%、平均強度は最大の208.5でした。ボタン自身の輪郭だけが形を変え、他は静止したままなので面積は小さいのですが、その一点の変化が鋭い境界のため強度は高くなります。

  // PULL_RESISTANCEで割って1-exp(-x)を適用すると、距離が伸びるほど増加が鈍る
  // (引くほど硬くなる)ゴムのような抵抗曲線になります。
  // 離すとばね(K, C)が1まで引き戻します。
  var PULL_RESISTANCE = 130, MAX_STRETCH = 0.9, K = 260, C = 8;
  var dist = 0, stretch = 1, vel = 0, target = 1, dragging = false, startX = 0, raf = null, last = 0;

  function stretchFromDist(d) {
    var n = Math.max(0, d) / PULL_RESISTANCE;
    return 1 + MAX_STRETCH * (1 - Math.exp(-n));
  }
  function paint() { btn.style.transform = 'scaleX(' + stretch.toFixed(3) + ')'; }
  function springTick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    var accel = -K * (stretch - target) - C * vel;
    vel += accel * dt;
    stretch += vel * dt;
    paint();
    if (Math.abs(stretch - target) < 0.002 && Math.abs(vel) < 0.01) { stretch = target; vel = 0; paint(); raf = null; return; }
    raf = requestAnimationFrame(springTick);
  }

05吊り下げた角度の揺れ — 上の一点から吊られた値が左右に揺れ、揺れ幅が徐々に縮む

ボタンを横に引いて離すと、固定した支点を中心に角度値が前後に揺れ、揺れの幅が時間とともに縮んでやがて静止します。メニューの上から吊られているように見える通知ボタンや、装飾的なヘッダーのアクションボタンは、この揺れによって画面に固定されているのではなく物理的に吊るされている印象を与えます。

吊り下げ角度値振動 — 上の一点に吊るされた値が左右に角度の値を行き来しながら小さくなっていく方式
新しいタブで開く

05 · 吊り下げ角度値振動 — 上の一点に吊るされた値が左右に角度の値を行き来しながら小さくなっていく方式 — 計測 累積面積 6.13% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRAVITYARM_LENDAMPING

実測値が語ること

05は01・02と並んで23フレーム中23フレームすべてで動き、累積面積6.13%・平均強度168.3でした。振り子自身が静止角度を何度も通り過ぎる周期運動のため、測定区間のほぼ全体で揺れが続きます。

  // 計算レイヤー: 角加速度 = -(GRAVITY/ARM_LEN)*sin(angle) - DAMPING*avel。
  // angleは度数なので、sin()の前にラジアンへ変換し、後で度数に戻します(RAD定数)
  // — この変換を省くと復元力が57分の1になり、揺れがすでに止まったように見えてしまいます。
  var GRAVITY = 2000, ARM_LEN = 110, DAMPING = 0.42, RAD = 180 / Math.PI;
  var angle = 0, avel = 0, dragging = false, last = 0, running = false;

  function paint() { arm.style.transform = 'rotate(' + angle.toFixed(2) + 'deg)'; }
  function tick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    avel += (-(GRAVITY / ARM_LEN) * Math.sin(angle / RAD) * RAD - DAMPING * avel) * dt;
    angle += avel * dt;
    paint();
    if (Math.abs(angle) < 0.2 && Math.abs(avel) < 0.6) { angle = 0; avel = 0; paint(); running = false; return; }
    raf = requestAnimationFrame(tick);
  }

06質量値の違いによる押し込み速度の差 — 質量値が大きいほど押し込み・復帰の速度値が遅くなる

3つのボタンはまったく同じばね・減衰の式と定数を使いますが、それぞれ異なる質量値をあらかじめ持っていて、同じ力でも重いボタンほど目に見えて遅く押し込まれ、遅く戻ります。重さを感じさせたい削除ボタンや、慎重な操作を促したい決済ボタンは、この遅い反応であえて「一呼吸」を求めます。

質量値による押し込み速度差 — 決めた質量の値が大きいほど押し込みと復帰の速度の値が遅くなる方式
新しいタブで開く

06 · 質量値による押し込み速度差 — 決めた質量の値が大きいほど押し込みと復帰の速度の値が遅くなる方式 — 計測 累積面積 14.093% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MASSKPRESS_DEPTH

実測値が語ること

06は9つの中で最大の累積面積14.09%を記録し、23フレームすべてで動き、平均強度は136.1でした。同じフレームの中で3つの独立したばねが同時にアニメーションするため、動いた面積が積み重なります。

  var K = 320, C = 8, PRESS_DEPTH = 12;
  var MASS = { light: 0.5, medium: 1.4, heavy: 3.6 };
  function makeBtn(el, key) {
    return { el: el, mass: MASS[key], depth: 0, vel: 0, target: 0 };
  }
  var items = [
    makeBtn(document.getElementById('bwmLight'), 'light'),
    makeBtn(document.getElementById('bwmMedium'), 'medium'),
    makeBtn(document.getElementById('bwmHeavy'), 'heavy')
  ];
  var last = 0, raf = null;

  function paint(it) { it.el.style.transform = 'translateY(' + it.depth.toFixed(2) + 'px)'; }
  function tick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    for (var i = 0; i < items.length; i++) {
      var it = items[i];
      var accel = (-K * (it.depth - it.target) - C * it.vel) / it.mass;
      it.vel += accel * dt;
      it.depth += it.vel * dt;
      paint(it);
    }
    raf = requestAnimationFrame(tick);
  }

07投げた速度値の減衰 — 離した瞬間の速度値のまま動き続け、その値が時間とともに縮んで止まる

ボタンをフリックすると、離した瞬間の速度値がそのまま動きに渡され、その速度値は毎フレーム指数関数的に縮んでいき、やがてボタインは滑るように止まります。軽く投げて離したフローティングボタンや、スワイプの後に滑っていくアクションボタンは、この減衰によって台本通りのスライドではなく本物の勢いのように見えます。

投げ速度値減衰移動 — 離した瞬間の速度の値のまま動き続け、その値が徐々に小さくなって止まる方式
新しいタブで開く

07 · 投げ速度値減衰移動 — 離した瞬間の速度の値のまま動き続け、その値が徐々に小さくなって止まる方式 — 計測 累積面積 5.038% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FRICTIONSTOP_VELCHIP_R

実測値が語ること

07は23フレームのうち21フレームで動き、累積面積5.04%・平均強度161.8でした。測定区間が終わる少し手前で滑りが止まるため、最後の数フレームは静止しています。

  // その速度値自体もFRICTIONの比率で指数的に縮みます — vel *= FRICTION**dt。
  // 壁に当たるとその軸の速度だけ0になるので、着地した位置でそのまま止まります。
  var FRICTION = 0.05, STOP_VEL = 6, CHIP_R = 24;
  var x = 100, y = 60, vx = 0, vy = 0, last = 0, raf = null;
  var dragging = false, samples = [];

  function bounds() { return { w: field.clientWidth, h: field.clientHeight }; }
  function paint() { chip.style.transform = 'translate(' + (x - CHIP_R).toFixed(1) + 'px,' + (y - CHIP_R).toFixed(1) + 'px)'; }
  function tick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    var decay = Math.pow(FRICTION, dt);
    vx *= decay; vy *= decay;
    x += vx * dt; y += vy * dt;
    var b = bounds();
    if (x < CHIP_R) { x = CHIP_R; vx = 0; }
    if (x > b.w - CHIP_R) { x = b.w - CHIP_R; vx = 0; }
    if (y < CHIP_R) { y = CHIP_R; vy = 0; }
    if (y > b.h - CHIP_R) { y = b.h - CHIP_R; vy = 0; }
    paint();
    if (Math.abs(vx) < STOP_VEL && Math.abs(vy) < STOP_VEL) { vx = 0; vy = 0; raf = null; return; }
    raf = requestAnimationFrame(tick);
  }

08離した後の揺れの減衰 — 離した瞬間から形の値が左右に揺れ、揺れ幅が縮んで止まる

ボタンを離すと、経過時間がそのまま減衰する正弦波の式に代入され、離した直後から形の値が左右に揺れ、その揺れは速度値をまったく積み上げることなく自分で縮んでいきます。長押しで確定するボタンやエラー状態を知らせるリトライボタンは、この即座の揺れによって「離した瞬間」をはっきり示せます。

解放後の揺れ値減衰 — 指を離した時点の形の値が左右に揺れ、その揺れ幅の値が小さくなって止まる方式
新しいタブで開く

08 · 解放後の揺れ値減衰 — 指を離した時点の形の値が左右に揺れ、その揺れ幅の値が小さくなって止まる方式 — 計測 累積面積 1.779% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

AMPLITUDE0DECAYFREQ

実測値が語ること

08は23フレーム中9フレームでしか動かず、9つの中で最少で、累積面積1.78%・平均強度115.0でした。式に含まれる指数減衰のため、測定区間が終わるずっと前に揺れが見える閾値を下回ります。

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 計算レイヤー: angle(t) = AMPLITUDE0 * e^(-DECAY*t) * sin(FREQ*t) — 減衰する正弦波が
  // 左右の回転角度を直接計算します(毎フレーム代入される値で、キーフレームではありません)。
  // DECAYが大きいほど早く収まり、FREQが大きいほど揺れが細かくなります。
  var AMPLITUDE0 = 9, DECAY = 5.2, FREQ = 26;
  var t0 = 0, raf = null, running = false;

  function paint(angle) { btn.style.transform = 'rotate(' + angle.toFixed(2) + 'deg)'; }
  function tick(now) {
    var t = (now - t0) / 1000;
    var angle = AMPLITUDE0 * Math.exp(-DECAY * t) * Math.sin(FREQ * t);
    paint(angle);
    if (Math.abs(angle) < 0.15 && t > 0.15) { paint(0); running = false; raf = null; return; }
    raf = requestAnimationFrame(tick);
  }

09固定地点への吸着 — 離した位置値が複数の固定値のうち最も近いものへ引き寄せられ、弾むように収まる

ボタンをドラッグして離すと、3つの固定値のうち離した位置に最も近いものが新しい目標として選ばれ、01・06と同じばねの式がそこへ小さな行き過ぎと戻りを伴って引き寄せます。複数の位置のどれかに収まるトグルボタンや、グリッドに揃うウィジェットボタンは、この吸着によって静止位置が偶然ではなく必然のように感じられます。

固定地点値へのスナップ — 離した位置の値が最も近い固定地点の値に引き寄せられ、弾むように合わさる方式
新しいタブで開く

09 · 固定地点値へのスナップ — 離した位置の値が最も近い固定地点の値に引き寄せられ、弾むように合わさる方式 — 計測 累積面積 6.549% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ANCHORSKnearestAnchor

実測値が語ること

09は23フレームのうち18フレームで動き、累積面積6.55%・平均強度111.8でした。動きが止まるのは離した直後の一瞬だけで、最も近い固定値の計算がまだ目標を選び終えていない間だけです。

  // 計算レイヤー: 離した位置の値(x)から、ANCHORS配列のうち最も近い固定値を
  // 目標として選び、ばね(K, C)がわずかな行き過ぎと戻りで所定の位置へスナップさせます。
  var ANCHORS = [30, 110, 190], K = 340, C = 10, BTN_R = 20;
  var x = ANCHORS[0], vel = 0, target = ANCHORS[0], dragging = false, startX = 0, startBtnX = 0, raf = null, last = 0;

  function nearestAnchor(px) {
    var best = ANCHORS[0], bestD = Infinity;
    for (var i = 0; i < ANCHORS.length; i++) {
      var d = Math.abs(ANCHORS[i] - px);
      if (d < bestD) { bestD = d; best = ANCHORS[i]; }
    }
    return best;
  }
  function paint() { btn.style.transform = 'translateX(' + x.toFixed(1) + 'px)'; }
  function tick(now) {
    var dt = Math.min((now - last) / 1000, 0.032);
    last = now;
    var accel = -K * (x - target) - C * vel;
    vel += accel * dt;
    x += vel * dt;
    paint();
    if (Math.abs(x - target) < 0.05 && Math.abs(vel) < 0.4) { x = target; vel = 0; paint(); raf = null; return; }
    raf = requestAnimationFrame(tick);
  }

値がパターンを変える基準 — 減衰・質量・境界の基準

01の減衰定数が決めるのは「通り過ぎるか平らに収まるか」であって押し込む深さではない理由

01の減衰定数Cを7から臨界減衰に近い値へ上げても、PRESS_DEPTHもボタンが動く距離も変わりません。変わるのは、深さ値が目標を通り過ぎて戻るか(アンダーダンプ)、それとも通り過ぎずにそのまま収まるか(オーバーダンプまたは臨界減衰)だけです。

05の減衰定数が決めるのは「揺れがどれだけ続くか」であって最初の揺れ幅ではない理由

05のDAMPING定数は毎フレーム角速度に掛けられるだけなので、一度引いたときの最初の角度の大きさには一切触れません。決めるのは、avelが十分小さくなって止まるまでに振り子が0を何回横切るかで、値を下げると2〜3回の揺れが10回を超える揺れへと引き伸ばされます。

09のばね定数が決めるのは「どれだけ速く吸着するか」であってどの固定値が選ばれるかではない理由

09のK定数は、nearestAnchor()がANCHORS配列からすでに目標を選び終えたあとにだけ働きます。Kを上げ下げすると、その選ばれた固定値までの差をどれだけ速く詰めるかが変わるだけで、3つのうちどれが選ばれるかには一切関わりません。

この3つの定数は、この記事の9パターンが他のすでに扱った記事とつながる場所でもあります。受賞級ボタンインタラクション(236)は、押し込み・引き寄せの各項目をCSSカスタムプロパティとトランジションの時間設定(@property・translate)から動かしていて、これら9つのような毎フレームの「加速度→速度→位置」ループは使っていません。CTAボタンのマイクロインタラクションUIパターン(352)は、紙吹雪が飛び散る項目の落下を、ボタン自身にではなく散らばった粒子に一定の重力値をかけて計算していて、そのグリッチ分割の項目だけが両記事を通じてただ一つ、シード付き擬似乱数で動く項目です — こちらの9つが完全に決定的な積分器であるのとは対照的です。Verletソフトボディ物理(306)は9つの場面すべてをVerlet積分で計算していて、これら9つがvelという速度値を明示的に持つのとは違い、現在位置と一つ前の位置だけから次の位置を求めます — どちらも本物の毎フレーム物理ループですが、加速度を動きに変える手段が異なります。ボタン 押し込み css の挙動を手元でも試したい場合は、この9項目を1つのzipにまとめてあります — パスワードxuen7cg2で開けば、上で読んだのと同じ定数がそのままindex.htmlに入っています。

どこで壊れるか — 連打・長いドラッグ・低スペック端末

ばねが収まる前にもう一度押すとどうなるか

01と06のpointerdownハンドラは、targetをPRESS_DEPTHへ戻すだけで、depthやvelを先にリセットしません。そのため、最初の揺れ戻しが終わる前に二度目の押し込みが入ると、すでに動いている速度に新しい引っぱりが上乗せされ、素早い連打は単発の押し込みよりも明らかに大きなオーバーシュートを生みます。

曲線が想定していない距離までドラッグするとどうなるか

04のstretchFromDist()はポインタの距離を1-exp(-n)という、距離が伸びるほど急に鈍る曲線に通しているので、PULL_RESISTANCEの3倍引いても少し超えた程度とほぼ同じ見た目になります — 抵抗自体は上がり続けますが、伸び値ではもう区別がつきません。逆に02のclampPos()は、ポインタ自体がどれだけ枠の外へ出ても、浮動ボタンの位置値を枠の内側に留め続けます。

低スペックやバックグラウンドの端末ではどうなるか

01・03・04・05・06・07・09の7つは、毎フレームのdtを0.032秒で上限を切っています。そのため、バックグラウンドのタブが復帰したときや低スペック端末でフレームが飛んだときのような長い間隔があっても、大きすぎるdtが積分器の位置値を本来より遠くまで飛ばすことはありません。02にはその上限がなく、レンダリングされたフレームごとに一定の割合で補間するだけなので、フレームレートが低いと実時間ではポインタに追いつくまで目に見えて長くかかります。08はこの問題自体を避けていて、毎フレームのdtを積み上げるのではなくperformance.now()から経過時間を直接読むので、フレームレートに関係なく揺れは同じ実時間で進みます。

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

項目 定数 値 何が変わるか
01 押し込みのオーバーシュート C 7 → 40 同じ押し込みで同じ深さまで届くのは変わりませんが、減衰比が1に近づくため通り過ぎが完全になくなり、跳ね返るような戻りが一回でしっかり止まる動きに変わります
05 振り子の揺れ DAMPING 0.42 → 0.08 同じ引き方で最初の揺れ幅は変わりませんが、振り子が静止するまでに0を横切る回数が2〜3回から10回を超える回数へ増え、目に見えて長く吊られたまま揺れ続けます
09 固定地点への吸着 K 340 → 120 同じ離し方で同じ最も近い固定値が選ばれるのは変わりませんが、位置値がそこへ向かう動きがほぼ即座のスナップから、はっきり長く柔らかい移動へ変わります

9つのどれもaria-live領域を持っていません — ここでのリアルタイムなフィードバックは常に視覚的で、読み上げられることはありません。01・06・08はARIA属性を一切持たず、普通の<button type="button">とその表示内容だけに頼っています。02のドラッグ領域と05の揺れ領域はそれぞれrole="application"とaria-labelを持ち(05にはキーボードフォーカス用のtabindex="0"も付いています)、03の床の目印・04の固定値の目印・05の支点と腕はすべて装飾用としてaria-hidden="true"です。02・05・07・09の操作要素にはそれぞれ動作を説明するaria-labelがあります。9つともprefers-reduced-motion: reduceを確認していて、一致した場合は自動再生のプレビューループを止め、操作された値をそのまま静止状態へ切り替えます — 07は速度の両成分をゼロにするのでフリックしても一切動かず、08は角度をちょうどゼロで描くので離しても揺れは起きず、残りの7つは押し込み・落下・伸び・揺れ・吸着後の静止値へアニメーションを介さず直接切り替わります。

FAQ

ボタン クリック アニメーションのようなこの動きに物理エンジンやライブラリは必要ですか?

必要ありません。9つとも普通のrequestAnimationFrameループの中で、位置値や速度値、いくつかの定数といったただの数値を計算しているだけです。物理エンジンをインポートしている項目はなく、9つのファイルはどれもブラウザ自身のAPI以外を読み込んでいません。

01のオーバーシュートのパターンと09の吸着のパターンは同じ計算を共有していますか?

すでに共有しています。09は01(と06)とまったく同じばね・減衰の式を再利用していて、違うのは最初の一手だけです — nearestAnchor()がばねの引き寄せ先となる固定値を先に選び、そのあとは同じaccel・vel・位置の計算に引き継がれます。

この9つのどれかは加速度センサーのような実際のデバイスセンサーを読んで押し込みの感触を出していますか?

いいえ。上で挙げたすべての値は、ポインタの座標(clientX・clientY)と各ファイル冒頭で宣言された固定の定数だけから計算されています。9つのどれも、デバイスのモーションセンサーやゲームパッド、外部のデータには一切触れていません。

出典: MDN — transition · MDN — easing-function · MDN — requestAnimationFrame · MDN — Pointer events · MDN — prefers-reduced-motion · MDN — pointerdown event

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

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