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

ボタン グループ ui 9選 — 隣り合うボタンが一つに動く仕組み

ボタン グループ ui: 9つのうち1つはアクティブボタンの値が変わるとスライド表示子の位置の値が実測座標に沿って移動し、1つはグループの幅の値が縮むと溢れたボタンがもっと見る値の中に折り畳まれ、もう1つは方向キーでフォーカスの値が移る際にフォーカスリングの位置の値がボタンの間を滑るように移動します。

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

9つのどれもサーバーに問い合わせず、localStorageに書き込まず、Math.randomも使いません。これらのトグル ボタン グループの各パターンはrequestAnimationFrameと少数の定数だけで動き、変わる状態値はいつも一つだけです——どのボタンがアクティブか、押されているか、フォーカスされているかのいずれかで、他のボタンのマークアップには触れません。この範囲の狭さのおかげで、実際に動く値の名前を知るだけで、どのボタンにこの9パターンのどれが合うか、コードを開く前に判断できます。

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

9つともrequestAnimationFrameを少なくとも一度は呼び、CSSトランジションだけに任せず、その呼び出しの中で実際の値を計算し直します。01番のtickRipples()はクリックした波動の半径を共有境界線自身の実測範囲まで切り取り、02番のloop()はSTIFFとDAMPによるばねをアクティブボタン自身のxとwに向けて積分し、03番のloop()はポインター距離の値をsmoothstepとSMOOTHの補間にかけて区切り線ごとに処理し、04番のrender()は各ボタンのdepthをSTIFFとDAMPのばねで1を超えて押し進めてから収め、05番のResizeObserverはポインタードラッグや自動再生ループがグループ自身の幅を変えるたびにOVERFLOW_BUFFER(24px)を加えた部分和のカットオフを再計算し、06番のflip()は方向転換の前後で全ボタンのgetBoundingClientRect()を読み取り、その差分だけをFLIP_MS(480ms)かけて戻し、07番のanimate()はラベル自身のclip-path表示をeaseOutCubicでイージングし、08番のselect()はフォーカスリングの位置を新しくフォーカスされたボタン自身の実測座標へイージングし、09番のstep()はhaltRで頭打ちになる波動の広がりと、それとは別の減衰するサイン振動の揺れを同時に計算します。リスナーはすべて作られたタイマーではなく実際の操作に応えます——01番と02番はそれぞれclickと、自分の境界線や表示子の位置を再計測するresizeを組み合わせ、03番はクリックなしでpointermoveを追いpointerleaveで消し、04番と08番はどちらもclickと方向キー用のkeydownを組み合わせ、05番はもっと見るボタンのclickと、ウィンドウのリサイズではなく手でグループ幅を変えるドラッグハンドルのpointerdown・pointermove・pointerupに応え、06番は自分の方向転換ボタンのclickだけに応え、07番はクリックもリサイズもなくpointerenter・pointerleave・focus・blurに応え、09番はclickではなくpointerdownに応えます。自動再生の循環は.is-demoクラスを通じて02・06・07・08番だけにあり、Math.randomは9つのどこにも使われていません。prefers-reduced-motion: reduceの下では、9つは一つの共通ルールではなく4つの異なる短縮方法に分かれます——01番は計算を保ったままRIPPLE_MSを1に縮め、02番はばねの目標値へ積分せずボタンへ直接スナップし、04・07・09番はそれぞれ自分のステップ関数自体を省略して深さ・clip-path・揺れを目標値へ直接移し、03番は自身の補間係数が1に落ちて区切り線が毎フレーム即座にスナップし、05番はフレームごとのループを一切使わずグループの最終幅を直接代入し、06番はflip()がFLIPの矩形を記録する前に処理を抜け、ネイティブなレイアウトだけが変形なしに行を組み直します。こうした複数ボタンの連動実装を調べると、共有境界線・アクティブスライド・区切り線フェード・排他的押下・溢れ折り畳み・方向転換・ラベル表示・フォーカススライド・無効時振動という、まさにこの9つの軸に行き着くことが多く、隠れた10番目の軸はありません。

パターン別コード

01共有境界線マージ — 隣り合う二つのボタンの内側の境界線の値が一つに重なり、二重に見えない方式

ボタンが接する内側の境界線の値はBLEED_PX(6px)によってあえて重ね合わされ、どのボタンがアクティブでも境界線が二重に見えません。ツールバーの整列ボタン群やフィルターオプションのボタン群にこの方式が向いていて、クリックした波動はclip-pathで共有境界線自身の実測された左右の範囲までしか切り取られないため、隣のボタンまでハイライトが越えて広がることがありません。

共有境界線マージ — 隣り合う二つのボタンの内側の境界線の値が一つに重なり、二重に見えない方式
新しいタブで開く

01 · 共有境界線マージ — 隣り合う二つのボタンの内側の境界線の値が一つに重なり、二重に見えない方式 — 計測 累積面積 2.754% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

margin-left: -1.5px seamclip-path bleedBLEED_PX 6
  function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }

  function tickRipples(ts) {
    for (var i = ripples.length - 1; i >= 0; i--) {
      var r = ripples[i];
      var t = Math.min(1, (ts - r.start) / RIPPLE_MS);
      var radius = r.from + (r.to - r.from) * easeOutQuad(t);
      var left = Math.max(r.minClip, r.originX - radius);
      var right = Math.min(r.maxClip, r.originX + radius);
      r.el.style.width = radius * 2 + 'px';
      r.el.style.height = radius * 2 + 'px';
      r.el.style.marginLeft = -radius + 'px';
      r.el.style.marginTop = -radius + 'px';
      r.el.style.opacity = String(1 - t);
      // clip-path で波動が実測境界線の外に出られないよう切り取る
      r.el.style.clipPath = 'inset(-9999px ' + Math.max(0, r.originX + radius - right) + 'px -9999px ' + Math.max(0, left - (r.originX - radius)) + 'px)';
      if (t >= 1) { r.el.remove(); ripples.splice(i, 1); }
    }
  }

数値が語ること

01番は23フレーム中11フレームで動きながら、累積面積2.75%・平均強度47.5を記録しますが、これは切り取られた波動が自分の境界線の実測幅の内側にしか描かれず、ボタン行全体を塗り替えないためです。

02アクティブ表示子スライド — アクティブボタンの値が変わると強調背景の位置の値が実測座標に沿って隣のボタンへ滑る方式

アクティブ値が別のボタンに移ると、強調背景の位置の値が二つのボタンの実測座標の間を滑るように移動します。タブ型ボタングループや並び順選択ボタンにこの方式が向いていて、STIFFとDAMPのばね積分が目標に着いた後も数フレームxとwを計算し続けるため、狙い先の幅に応じて表示子自身の幅が呼吸するように変わります。

アクティブ表示子スライド — アクティブボタンの値が変わると強調背景の位置の値が実測座標に沿って隣のボタンへ滑る方式
新しいタブで開く

02 · アクティブ表示子スライド — アクティブボタンの値が変わると強調背景の位置の値が実測座標に沿って隣のボタンへ滑る方式 — 計測 累積面積 4.878% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFF 210 / DAMP 18translateX + scaleXspring integrate
  function loop(ts) {
    if (!last) last = ts;
    var dt = Math.min(0.032, (ts - last) / 1000);
    last = ts;

    if (!cycleStart) cycleStart = ts;
    if (isDemo && !reduce) {
      var idx = Math.floor((ts - cycleStart) / CYCLE_MS) % btns.length;
      if (idx !== lastIdx) { lastIdx = idx; goTo(idx, ts); }
    }

    var ax = STIFF * (targetX - x) - DAMP * vx;
    var aw = STIFF * (targetW - w) - DAMP * vw;
    vx += ax * dt; x += vx * dt;
    vw += aw * dt; w += vw * dt;
    render();

    requestAnimationFrame(loop);
  }

数値が語ること

02番は23フレーム中13フレームで動きながら、累積面積4.88%・平均強度102.6を記録しますが、これは表示子が見た目に到着した後もばねの速度計算が数フレーム残り、完全には即座に止まらないためです。

03区切り線フェード — ボタン間の区切り線の透明度の値がポインターが隣のボタンに近づくほど薄くなる方式

ポインター距離の値が区切り線に近づくほど、その区切り線の透明度の値が下がり、隣のボタンとの境が薄くなります。設定パネルの表示切替ボタン群にこの方式が向いていて、targetOpacity()がポインター距離をスムーズステップの減衰にかけ、SMOOTHの補間で行全体ではなく区切り線一本ずつを個別にフェードさせます。

区切り線フェード — ボタン間の区切り線の透明度の値がポインターが隣のボタンに近づくほど薄くなる方式
新しいタブで開く

03 · 区切り線フェード — ボタン間の区切り線の透明度の値がポインターが隣のボタンに近づくほど薄くなる方式 — 計測 累積面積 0.297% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FALLOFF_PX 46smoothsteplerp SMOOTH .22
  function loop(ts) {
    if (!demoStart) demoStart = ts;
    var rect = root.getBoundingClientRect();
    var px = isDemo && !reduce ? demoPointerX(ts, rect.width) : pointerX;
    var centers = seamCenters();

    for (var i = 0; i < seams.length; i++) {
      var target = px == null ? 1 : targetOpacity(Math.abs(px - centers[i]));
      opac[i] += (target - opac[i]) * (reduce ? 1 : SMOOTH);
      seams[i].style.opacity = opac[i].toFixed(3);
    }

    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);

数値が語ること

03番は23フレーム中13フレームで動きながら、累積面積0.30%・平均強度24.9という最も低い値を記録しますが、これは幅1pxの区切り線だけが塗り替わり、ボタン自体は一切変化しないためです。

04排他的押下状態 — 一つのボタンの押下値がオンになると、他の全てのボタンの押下値が同時にオフになる方式

一つのボタンの押下値がオンになった瞬間、他の全てのボタンの押下値が同時にオフになり、常に一つの値だけがオンのままです。表示方式選択ボタンや単一選択のフィルターボタン群にこの方式が向いていて、STIFFとDAMPのばねが各ボタンのdepth値を1より先まで押し進めてから収まるため、押した瞬間に影のぼかしと位置がわずかに行き過ぎてから戻ります。

排他的押下状態 — 一つのボタンの押下値がオンになると、他の全てのボタンの押下値が同時にオフになる方式
新しいタブで開く

04 · 排他的押下状態 — 一つのボタンの押下値がオンになると、他の全てのボタンの押下値が同時にオフになる方式 — 計測 累積面積 9.53% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFF 320 / DAMP 14translateY depthspring overshoot
  btns.forEach(function (b, i) {
    b.addEventListener('click', function () { human(); setChecked(i); b.focus({ preventScroll: true }); });
  });

  root.addEventListener('keydown', function (e) {
    if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
    human();
    var dir = e.key === 'ArrowRight' ? 1 : -1;
    var next = (checkedIdx + dir + btns.length) % btns.length;
    setChecked(next);
    btns[next].focus({ preventScroll: true });
    e.preventDefault();
  });

  function render(i) {
    var d = depth[i];
    var overshootLift = Math.max(0, -(d - 1)) * -2; // オーバーシュート区間(d>1)では少し深く押されたように見せる
    var y = d * 3 + (d > 1 ? (d - 1) * 4 : 0);
    var blur = 14 - d * 8;
    var offY = 8 - d * 6;
    btns[i].style.transform = 'translateY(' + y.toFixed(2) + 'px)';
    btns[i].style.boxShadow = '0 ' + Math.max(1, offY).toFixed(1) + 'px ' + Math.max(4, blur).toFixed(1) + 'px rgba(23,20,26,.34)';
    btns[i].classList.toggle('rep__btn--on', d > 0.5);
  }

数値が語ること

04番は23フレーム中15フレームで動きながら、累積面積9.53%・平均強度77.3を記録しますが、これは行き過ぎの間、押されたボタンだけでなく全ボタンの影のぼかし値を毎フレーム計算し直すためです。

05溢れ折り畳みもっと見る — グループの幅の値が狭くなると溢れたボタンが一つのもっと見る値に折り畳まれる方式

グループの幅の値がボタンの合計幅の値より狭くなると、溢れたボタンが一つのもっと見る値に折り畳まれます。狭い画面のツールバーボタン群にこの方式が向いていて、部分和のループが各ボタンの実測幅にOVERFLOW_BUFFER(24px)を加えながら次のボタンが入らなくなる直前まで合計し、その先を隠す位置を決めます。

溢れ折り畳みもっと見る — グループの幅の値が狭くなると溢れたボタンが一つのもっと見る値に折り畳まれる方式
新しいタブで開く

05 · 溢れ折り畳みもっと見る — グループの幅の値が狭くなると溢れたボタンが一つのもっと見る値に折り畳まれる方式 — 計測 累積面積 6.63% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

OVERFLOW_BUFFER 24getBoundingClientRect cachewidths cutoff sum
    var sumAll = widths.reduce(function (s, w) { return s + w; }, 0) + gap * (widths.length - 1);
    var cutoff = widths.length;
    if (sumAll > avail) {
      var sum = 0;
      cutoff = 0;
      for (var i = 0; i < widths.length; i++) {
        var add = widths[i] + (i > 0 ? gap : 0);
        if (sum + add + moreWidth > avail) break;
        sum += add;
        cutoff = i + 1;
      }
      cutoff = Math.max(1, cutoff);
    }

    btns.forEach(function (b, i) { b.hidden = i >= cutoff; });
    var overflowCount = widths.length - cutoff;
    more.hidden = overflowCount === 0;
    more.textContent = 'もっと見る ▾ (' + overflowCount + ')';

数値が語ること

05番は23フレーム中22フレームともっとも多く動きながら、累積面積6.63%・平均強度121.3を記録しますが、これはドラッグや自動再生に合わせて部分和の計算がほぼ毎フレーム再実行されるためです。

06縦横方向転換 — グループの方向の値が変わるとボタンの位置の値が実測座標の間を移動して並びが組み直される方式

方向の値が横から縦に変わると、各ボタンの位置の値が旧座標から新座標まで移動し、並びが組み直されます。レスポンシブツールバーやサイドバーに折り畳まれるボタン群にこの方式が向いていて、flip()が並び替え前後のgetBoundingClientRect()を全ボタンについて読み取り、その差分だけをFLIP_MS(480ms)かけて戻します。

縦横方向転換 — グループの方向の値が変わるとボタンの位置の値が実測座標の間を移動して並びが組み直される方式
新しいタブで開く

06 · 縦横方向転換 — グループの方向の値が変わるとボタンの位置の値が実測座標の間を移動して並びが組み直される方式 — 計測 累積面積 15.29% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLIP_MS 480FLIP Invert Playforced reflow
  function flip(apply) {
    var first = btns.map(function (b) { return b.getBoundingClientRect(); });
    apply();
    var last = btns.map(function (b) { return b.getBoundingClientRect(); });

    if (reduce) return; // 計算は保持しつつ即時反映し、アニメーションは省略する

    btns.forEach(function (b, i) {
      var dx = first[i].left - last[i].left;
      var dy = first[i].top - last[i].top;
      if (!dx && !dy) return;
      b.style.transition = 'none';
      b.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
      // 強制リフローで初期の transform を画面に反映させた後にだけ、戻す transition が再生される
      b.getBoundingClientRect();
      requestAnimationFrame(function () {
        b.style.transition = 'transform ' + FLIP_MS + 'ms cubic-bezier(.22,.9,.32,1)';
        b.style.transform = 'translate(0,0)';
      });
    });
  }

数値が語ること

06番は23フレーム中16フレームで動きながら、累積面積15.29%という最も高い値・平均強度120.7を記録しますが、これは一つのボタンだけでなくグループ全体のボタンが同時に自分の距離を移動するためです。

07アイコンラベル組合せ表示 — コンテナの幅の値が広がるとアイコンボタンの文字ラベルの幅の値が0から広がって現れる方式

コンテナの幅の値が基準を超えると、アイコンボタンのラベル幅の値が0から実際の文字幅まで広がります。レスポンシブなアクションボタン群やカード内のボタングループにこの方式が向いていて、ポインターが乗るかフォーカスが当たるとanimate()がeaseOutCubicのclip-pathで400msかけて、そのラベル自身の実測文字幅を目標に表示させます。

アイコンラベル組合せ表示 — コンテナの幅の値が広がるとアイコンボタンの文字ラベルの幅の値が0から広がって現れる方式
新しいタブで開く

07 · アイコンラベル組合せ表示 — コンテナの幅の値が広がるとアイコンボタンの文字ラベルの幅の値が0から広がって現れる方式 — 計測 累積面積 0.602% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

dur 400 easeOutCubicclip-path revealmeasured text width
  function render(cell) {
    var pct = (1 - cell.progress) * 100;
    cell.reveal.style.clipPath = 'inset(0 ' + pct.toFixed(2) + '% 0 0)';
    cell.label.style.opacity = String(cell.progress);
  }

  function animate(cell, to) {
    cell.target = to;
    var from = cell.progress;
    if (reduce) { cell.progress = to; render(cell); return; }
    if (cell.raf) cancelAnimationFrame(cell.raf);
    var dur = 400, t0 = 0;
    function step(ts) {
      if (!t0) t0 = ts;
      var t = Math.min(1, (ts - t0) / dur);
      cell.progress = from + (to - from) * easeOutCubic(t);
      render(cell);
      if (t < 1) cell.raf = requestAnimationFrame(step);
      else cell.raf = 0;
    }
    cell.raf = requestAnimationFrame(step);
  }

数値が語ること

07番は23フレーム中もっとも少ない9フレームで動きながら、累積面積0.60%・平均強度76.4を記録しますが、これは表示がポインターやフォーカスが当たった直後の短い間だけ再生され、常には動かないためです。

08キーボード方向キーフォーカススライド — 方向キーでフォーカスの値が移るとフォーカス枠の位置の値がボタン間の実測座標に沿って移動する方式

方向キーを押すたびにフォーカスの値が次のボタンへ移り、フォーカス枠の位置の値が二つのボタンの実測座標の間を移動します。アクセシブルなツールバーやキーボードで操作するオプションボタン群にこの方式が向いていて、select()が全ボタンのaria-checkedとtabIndexを更新する間、FOCUS_EASEのイージングがリング自身の左位置と幅を新しく選ばれたボタンの実測座標まで運びます。

キーボード方向キーフォーカススライド — 方向キーでフォーカスの値が移るとフォーカス枠の位置の値がボタン間の実測座標に沿って移動する方式
新しいタブで開く

08 · キーボード方向キーフォーカススライド — 方向キーでフォーカスの値が移るとフォーカス枠の位置の値がボタン間の実測座標に沿って移動する方式 — 計測 累積面積 8.261% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOCUS_EASE easeInOuttranslate + resize ringmeasured coord tween
  function select(i, focus) {
    btns.forEach(function (b, j) {
      b.setAttribute('aria-checked', String(j === i));
      b.tabIndex = j === i ? 0 : -1;
    });
    current = i;
    moveRing(i);
    if (focus) btns[i].focus({ preventScroll: true });
  }

  btns.forEach(function (b, i) {
    b.addEventListener('click', function () { human(); select(i, false); });
    b.addEventListener('keydown', function (e) {
      if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
      human();
      e.preventDefault();
      var dir = e.key === 'ArrowRight' ? 1 : -1;
      select((current + dir + btns.length) % btns.length, true);
    });
  });

数値が語ること

08番はここで強度最高と呼べる唯一の項目で、23フレーム中18フレームで動きながら累積面積8.26%・平均強度190.7を記録しますが、これはリング自体が明るいアクセントの輪郭なので、通過するピクセルの差分が大きく出るためです。

09無効時波動ブロック — 無効値がオンのボタンを押すと波動拡散の値が途中で止まり、代わりに振動の値で反応する方式

無効値がオンのボタンを押しても波動拡散の値は途中で止まり、代わりにボタンの振動の値が短く反応します。権限のないアクションボタンや送信完了後にロックされたボタンにこの方式が向いていて、波動の広がりはhaltRで頭打ちになる一方、SHAKE_FREQとSHAKE_DECAYによる別の減衰振動子がボタン自身の左右方向のサイン減衰振動を計算します。

無効時波動ブロック — 無効値がオンのボタンを押すと波動拡散の値が途中で止まり、代わりに振動の値で反応する方式
新しいタブで開く

09 · 無効時波動ブロック — 無効値がオンのボタンを押すと波動拡散の値が途中で止まり、代わりに振動の値で反応する方式 — 計測 累積面積 2.133% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHAKE_FREQ 26 / DECAY 6.5sin decay oscillatorHALT_RATIO .5 ripple
    function step(ts) {
      if (!t0) t0 = ts;
      var t = (ts - t0) / 1000;
      var tt = Math.min(1, (ts - t0) / dur);

      // 波動:0 から haltR まで減速しながら広がり停止(それ以上大きくならない)。フェードは拡散とは別に進む
      var spread = haltR * (1 - Math.pow(1 - Math.min(1, tt * 1.6), 2));
      cell.ripple.style.transform = 'translate(-50%,-50%) scale(' + Math.max(0.001, spread / 4) + ')';
      cell.ripple.style.opacity = String(Math.max(0, 1 - tt));

      // 振動:減衰振動子 — キャンバスのキーフレームではなく毎フレーム計算する
      var shakeT = Math.min(t, SHAKE_MS / 1000);
      var dx = SHAKE_AMPLITUDE * Math.sin(SHAKE_FREQ * shakeT) * Math.exp(-SHAKE_DECAY * shakeT);
      cell.btn.style.transform = 'translate(' + dx.toFixed(2) + 'px,0)';

      if (tt < 1) cell.raf = requestAnimationFrame(step);
      else { cell.raf = 0; cell.btn.style.transform = 'translate(0,0)'; }
    }
    cell.raf = requestAnimationFrame(step);

数値が語ること

09番は23フレーム中16フレームで動きながら、累積面積2.13%・平均強度45.7を記録しますが、これは振動子がそれらのフレームで計算を続けていても振れ幅自体が小さいままだからです。

値が位置と大きさを変える基準 — アクティブ値・幅値・フォーカス値

ラジオ ボタン グループの話に入る前に、位置と大きさの軸だけを見ると、9つのうち3つが単純なクラス切替より一歩先に進んでいることが分かります。02番の強調背景は自身の位置をアクティブなボタンに直接結び付けます——xとwはどちらもばねの目標値なので、表示子は今どのボタンへ滑っているかによって幅自体が広がったり狭まったりし、位置だけでは説明できません。05番の溢れ折り畳みは位置ではなく大きさを変えます——グループの幅の値が部分和のループを通じてカットオフの番号を決めるので、あるボタンが縮むよう指示されるのではなく、次のボタンがグループから溢れる前にまだ収まるかどうかだけが判定されます。08番のフォーカス枠はそれとは対照的に、大きさに触れず位置だけを変えます——FOCUS_EASEがリング自身の左位置をフォーカスされているボタンへ運び、幅はそのボタン自身の実測矩形に合わせて更新されるだけです。こうした実装を別の見た目に差し替えてもこれらの定数が指す値そのものは変わりません——02番のばねは今もxとwを目標にし、05番のカットオフは今も実測幅の合計を使います。変わるのは周りのクラス名だけです。

このアクティブ・幅・フォーカスという軸の違いは、こうしたパターンがこのページを離れて実際のツールバーへ移るときにこそ意味を持ちます。【コピペOK】CSS ボタン アニメーション 9選(SCSS + React)は一つのボタン自身のホバーや押下の値だけを単独で動かしますが、上の9パターンはどれもボタン同士の状態を同じグループの中で連動させます。css ボタン デザイン 9選 — キャンディグラデ・押し込み・ローディングはボタン自身の表面の見た目——グラデーション・押し込みの深さ・ローディングの塗り——を変えるだけで、隣り合うボタンの間を移動する値はありません。フローティング ボタン アイコン 9選 — 押す・広がる・動かすは一つのアイコンボタン自身の大きさや位置を広げたり動かしたりしますが、ここでのアクティブ表示子やフォーカス枠のように行全体で共有される値はありません。zipを一度he9ea98kで開けば、これら9パターンすべてが同時に開放され、項目ごとに開け直す必要はありません。

どこで壊れるか — 連続クリック、狭い画面、キーボードフォーカス

自分のタイミングを自分で計算する9パターンは、ユーザーが一つずつ丁寧にクリックしなくなった瞬間、3つの異なる形で壊れます。

スプリングが収まる前の連続クリック

04番のrender()はクリックのたびに最新のdepth[i]を読み直すので、ばねが収まる前に別のボタンをクリックしても、いったんゼロへ戻るのではなく同じオーバーシュートの目標だけが変わります。02番も同じで、loop()は常に最新のtargetXとtargetWへ向けてxとwを積分し続けるため、素早い二回目のクリックは動いているばねの向きを変えるだけで、最初からやり直すわけではありません。

狭い画面と折り畳みの余白

05番のカットオフループはボタンを丸ごと隠すことしかできないので、最初のボタン自身の幅にOVERFLOW_BUFFERを足した幅より画面が狭くなっても、もっと見るボタンは残ったまま全ボタンがその裏に折り畳まれます。06番のflip()は方向転換の直前に矩形を測るので、トランジションが終わる前にもう一度トグルをクリックすると、すでに動いている最中のfirstの矩形を古いまま渡してしまうことがあります。

ボタンが隠れた時のキーボードフォーカス

08番のselect()はtabIndexとフォーカスリングを一緒に動かすので、兄弟ボタンが隠れていてもこのロービングタブインデックスの仕組みは動き続けます。05番はこの仕組みと一切連動していません——フォーカスされたボタンがもっと見るへ折り畳まれると、hiddenが残した先へフォーカスが落ちるだけで、ボタンを隠す仕組みとフォーカスを動かす仕組みはここでは互いを一度も呼び出さないからです。

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

02番のDAMPを18から9に下げると、ばねにオーバーシュートが残り、表示子が目標のボタンをわずかに行き過ぎてから戻るため、スライドがより弾むように感じられます。05番のOVERFLOW_BUFFERを24から8pxに減らすと、ボタンがより長く展開されたまま残り、折り畳みが始まる位置が画面の端にずっと近づきます。08番のFOCUS_EASEをeaseInOutからlinearに変えると、出発と到着での減速がなくなり、リングは次のボタンへ一定速度で届くため、方向キーの移動が機械的できびきびした印象になります。

バリエーション 変わるもの 変わらないもの
スライド表示子の移動時間 DAMP 18 → 9 STIFFの値・ばねの式・どのボタンがアクティブかという値
溢れ折り畳みの余白 OVERFLOW_BUFFER 24 → 8 カットオフのループ自体・もっと見るのラベル自体・グループ幅の測定
フォーカス枠の移動速度 FOCUS_EASE easeInOut → linear radiogroupのロール・aria-checked・ArrowLeft/Rightのハンドラ

04番と08番だけが各ボタンにrole="radiogroup"とaria-checkedを設定し、ロービングなtabIndexと、方向キーで移動するたびのfocus({preventScroll:true})を備えています——これは、常に一つだけが選ばれているべきラジオ ボタン グループの意味をネイティブのラジオボタンと同じように守っているのがこの二つだけだからです。残りの7つはただのrole="group"ボタンのままです——01・02・06番はaria-pressedで自分の状態を映し、複数が同時に成り立ってよいため、05番は自分のもっと見るボタンにaria-expandedを、トラックにrole="toolbar"を付け、03・07・09番はホバー・表示・振動のどれも実際に選ばれている値を変えないため、押下状態を一切表しません。モーション削減は一つのルールではなく3つの短縮方法に分かれます——01番は波動の計算を保ったまま持続時間を1に縮め、02・04・07・08・09番はそれぞれ自分のステップ関数を省略して値を直接静止状態へ移し(08番のmoveRing()はreduce || !fromの分岐でトゥイーンを一切せずフォーカスリングを目標ボタンの実測座標へ即座にレンダリングします)、03・05・06番は自身のJSがすでに値を直接スナップさせているため、単純なscssのtransition: noneに任せます。

FAQ

これはトグル ボタン グループ 化の9パターンを一つの共通コンポーネントで作った設定違いですか、それぞれ別のスクリプトですか

それぞれ別のスクリプトです——上のデモはどれも自分専用の定数を持つ独立したファイルで、隣にあるコードフェンスはそのまま動く正確なブロックであり、変数だけ差し替えたテンプレートではありません。

01番の共有境界線は本物のCSSボーダーですか、それともclip-pathで作った見た目だけのものですか

両方が関わっています——境界線自体は負のmargin-leftで配置された本物の1px背景線で、clip-pathはクリックした波動をその境界線自身の実測された端の外へ視覚的に出さないよう切り取るだけです。

なぜ04番と08番だけがラジオ ボタン グループのロールを使い、他はただのボタンのままなのですか

常に一つだけが選ばれていなければならないパターンがこの二つだけだからです——role="radiogroup"とaria-checked、ロービングなtabIndexが支援技術にそれを伝える一方、残りは複数の押下状態を許すか、そもそも選択状態自体を表さないかのいずれかです。

Sources: MDN — ARIA toolbar role · MDN — ARIA group role · MDN — :focus-visible · MDN — Element.getBoundingClientRect()

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

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