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

ボトムナビゲーション 9選 — 状態値で変わる9つの転換パターン

CSS ボトムナビゲーション:9つのうち1つは選択値が変わると強調表示の位置と幅の値が新しい場所へ一緒に移動し、1つは通知件数の値が上がるとその表示の大きさの値が一瞬大きくなってから収まり、もう1つはスクロール方向の値が変わるとバー全体の縦位置の値が画面外と元の位置の間を行き来します。

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

九つのどれもサーバーへ通信したり、localStorageに書き込んだり、スクリプトのどこかでMath.randomを呼んだりすることはありません。どれも一つの状態値(どのタブが選ばれているか・押し続けた時間・スクロールの方向・機器のセーフエリアの余白がどれだけあるか)と、その値の変化が画面上でどう見えるかを決める少数の定数だけで作られた、小さく自己完結したボトムナビゲーション デザインです。この範囲の狭さのおかげで、実際に動いている値が何か — 選択の番号か、件数か、スクロールの方向か、ピクセル単位の余白か — を名付けるだけで、コードを開く前にこの9つのどれが今の画面に合うかが分かります。

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

9つすべてが自前のrequestAnimationFrameループで動いており、どれもMath.randomを呼ぶことはありません — すべてが乱数のスクリプトではなく実際の操作から一歩ずつ進むため、同じデモは訪れるたびに同じように再生されます。いくつかの項目はCSSトランジションだけに頼らず、フレームごとに実際のばねや減衰を計算しています。01番のspringTo()は共通のSPRING_K210・SPRING_C19のペアでインジケーターの位置と幅の両方をアクティブなタブへ引き寄せ、03番のbumpScale()はMath.expによるBUMP_ZETA4.8の減衰とBUMP_FREQのコサインを掛け合わせてバッジの一瞬の跳ね上がりを作り、04番のrender()は一つのliftTの値だけで高さ・拡大率・そしてSHADOW_BLUR_GAIN20を含む影を同時に動かし、05番のonScrollFrame()はscrollTopの変化量から実際の速度を求めて非対称なVEL_HIDE90・VEL_SHOW40のしきい値と比較し、06番のplaceActions()はFAN_ANGLES配列に実際の三角関数を適用して3つのボタンを実際のFAN_Rの半径上に配置し、07番のドラッグは指を離した後にインジケーターを最も近いタブへ引き寄せる別のばね(SPRING_K240)を持ち、08番のrender()はフレームごとにLERP_RATE10で緩和されるリアルタイムの余白値からバー全体の高さを再計算します。02番はFILL_RATEという一つの指数平滑の値だけで二つのタブの間を切り替え、09番はこの比較そのものを目的としています — 通常側のパネルはDURATION_MS320で緩和し、モーション削減側のパートナーは中間段階を挟まず同じ目標値を直接代入します。すべてのリスナーは実際の操作に応えるものであり、自動再生のスクリプトからは一つも来ません。02・04・09番はただのclickに応え、03・08番はEnterやSpaceのためのkeydownをclickと組み合わせ、01・05番はclickにresizeを加え、06番はclick・pointerdown・resizeを組み合わせ、07番だけが例外で、pointermove・pointerup・pointercancelをclick・pointerdown・resizeにさらに加えることで、ドラッグ全体がタップと同じように扱われます。9つすべてがisDemoという自動再生の状態から始まり、最初のクリック・ポインター操作・キー入力がhuman()を呼ぶまでその状態が続きます。human()はis-demoクラスを外し、台本通りの自動再生を完全に止めます。01番から05番はそれぞれ読み込み時に一度だけmatchMedia('(prefers-reduced-motion: reduce)')を確認し、一致すればループの開始そのものを飛ばしますが、06番から09番は同じフラグをループの起動には使わずisDemo && !reduceで自動再生の分岐だけを止めます — 09番だけはこの仕組みをモーション削減が何を変えるかを見せる比較そのものに使っている点が異なります。

パターン別コード

01アクティブ表示移動切替 — 選択タブが変わると強調表示の位置と幅の値が古い場所から新しい場所へ一緒に変わる方式

選択値が変わった瞬間、強調表示の横位置と幅の値が新しいタブの場所へ一緒に移動します。下部タブを切り替えるモバイル画面に向いており、共通のSPRING_KとSPRING_Cのペアが同じばねの式で短い移動も長い移動も同じように語ってくれるからです。

アクティブ表示移動切替 — 選択タブが変わると強調表示の位置と幅の値が古い場所から新しい場所へ一緒に変わる方式
新しいタブで開く

01 · アクティブ表示移動切替 — 選択タブが変わると強調表示の位置と幅の値が古い場所から新しい場所へ一緒に変わる方式 — 計測 累積面積 6.096% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_KtranslateXwidth
  function springTo(pos, vel, target, dt) {
    var acc = (target - pos) * SPRING_K - vel * SPRING_C;
    vel += acc * dt;
    pos += vel * dt;
    return [pos, vel];
  }

  function render() {
    indicator.style.transform = 'translateX(' + posX.toFixed(2) + 'px)';
    indicator.style.width = posW.toFixed(2) + 'px';
  }

数値が語ること

01番はフレーム全体の6.10%を累積で占め、平均強度32.0で、サンプリングした23フレームのうち17フレームで動いています。これはspringTo()がposXとposWの両方を、ばねが収まるまで毎フレーム同じSPRING_K210・SPRING_C19のペアから再計算するためで、インジケーターの移動とリサイズが別々にずれることなく同じ拍で揺れ動くからです。

02アイコン塗り切替 — 選択タブが変わるとその場所の塗り具合の値が低い状態から満ちた状態へ変わる方式

選択値がオンになった瞬間、その場所の塗り具合の値が最大まで上がり、他の場所の値は逆に下がります。アイコン中心の下部ナビゲーションに向いており、塗りと輪郭が入れ替わることで二つのアイコンが別々に点滅するのではなく一つの受け渡しに見えるからです。

アイコン塗り切替 — 選択タブが変わるとその場所の塗り具合の値が低い状態から満ちた状態へ変わる方式
新しいタブで開く

02 · アイコン塗り切替 — 選択タブが変わるとその場所の塗り具合の値が低い状態から満ちた状態へ変わる方式 — 計測 累積面積 1.267% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FILL_RATEPULSE_ZETAscale
  function loop(ts) {
    if (!lastTs) { lastTs = ts; cycleStart = ts; }
    var dt = ts - lastTs;
    lastTs = ts;

    if (isDemo) {
      var elapsed = ts - cycleStart;
      var idx = Math.floor(elapsed / HOLD_MS) % TAB_COUNT;
      if (idx !== activeIdx) setActive(idx, ts);
    }

    for (var i = 0; i < TAB_COUNT; i++) {
      var target = i === activeIdx ? 1 : 0;
      fillAmt[i] += (target - fillAmt[i]) * (1 - Math.exp(-FILL_RATE * dt));
    }

    render(ts);
    requestAnimationFrame(loop);
  }

数値が語ること

02番はフレーム全体の1.27%を累積で占め、平均強度56.3で、23フレームのうち14フレームで動いています。これはfillAmt配列だけが変わり、一つの値が同じ指数的なFILL_RATEで1へ上がる間、残りの値が0へ下がるためで、タブが切り替わるたびに起きても小さく収まった変化にとどまるからです。

03バッジ件数バウンド切替 — 通知件数の値が上がるたびその表示の大きさの値が一度大きくなってから戻る方式

件数の値が上がった瞬間、その表示の大きさの値が一瞬大きくなり、元の大きさに戻ります。未読通知数を示すタブに向いており、この一瞬の跳ね上がり自体が件数が変わったことを伝える唯一の手がかりになり、数字を読み直す必要がないからです。

バッジ件数バウンド切替 — 通知件数の値が上がるたびその表示の大きさの値が一度大きくなってから戻る方式
新しいタブで開く

03 · バッジ件数バウンド切替 — 通知件数の値が上がるたびその表示の大きさの値が一度大きくなってから戻る方式 — 計測 累積面積 0.217% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BUMP_ZETAtranslateYscale
  function bumpScale(t) {
    // 計算層: バッジのバンプ — 減衰コサイン振動
    if (t < 0 || t > BUMP_DUR) return 1;
    var amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
    return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
  }

  function bump(ts) {
    bumpStart = ts;
  }

  function increment(ts) {
    count = (count + 1) % 10;
    badge.style.display = 'inline-flex';
    bump(ts);
  }

  function render(ts) {
    var s = bumpScale(ts - bumpStart);
    badge.style.transform = 'scale(' + s.toFixed(3) + ')';
    strip.style.transform = 'translateY(' + (-pos).toFixed(2) + 'px)';
  }

数値が語ること

03番はフレーム全体の0.22%を累積で占め、平均強度97.3で、23フレームのうち20フレームで動いています。これはbumpScale()がincrement()が呼ばれた直後のBUMP_DUR320msという短い時間だけバッジを持ち上げ、BUMP_ZETA4.8の減衰とBUMP_FREQのコサインが掛け合わさっているためで、長く続く変化ではなく短く強い一撃になっているからです。

04中央浮上切替 — 中央タブの選択値がオンになるとその場所だけ高さの値が背景より大きくなり浮き上がる方式

中央の選択値がオンになった瞬間、その場所の高さと大きさの値が一緒に増え、他の場所より目立ちます。中央に主要機能を置いた下部ナビゲーションに向いており、一つのliftTの値が高さ・拡大率・影の三つを同時に動かすことで、単に大きくなるのではなく本当に浮き上がって見えるからです。

中央浮上切替 — 中央タブの選択値がオンになるとその場所だけ高さの値が背景より大きくなり浮き上がる方式
新しいタブで開く

04 · 中央浮上切替 — 中央タブの選択値がオンになるとその場所だけ高さの値が背景より大きくなり浮き上がる方式 — 計測 累積面積 2.599% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LIFT_PXSHADOW_BLUR_GAINtranslateY
  function render() {
    // 計算層: 物理的な影の近似 — 浮き上がる高さ(liftT)が大きいほどblur・yオフセットは大きくなり、alphaは薄くなる
    var ty = -(LIFT_PX * liftT);
    var scale = 1 + (SCALE_MAX - 1) * liftT;
    var blur = SHADOW_BLUR_BASE + SHADOW_BLUR_GAIN * liftT;
    var y = SHADOW_Y_BASE + SHADOW_Y_GAIN * liftT;
    var alpha = Math.max(0.08, SHADOW_ALPHA_BASE + SHADOW_ALPHA_GAIN * liftT);
    center.style.transform = 'translateY(' + ty.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
    center.style.boxShadow = '0 ' + y.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(10, 20, 40,' + alpha.toFixed(3) + ')';
  }

数値が語ること

04番はフレーム全体の2.60%を累積で占め、平均強度116.0で、23フレームのうち12フレームで動いています。これはliftTがtranslateY・scale・そしてSHADOW_BLUR_GAIN20を含む影のブラー・yオフセット・不透明度を一つの値でまとめて動かすためで、中央のタブが単に大きくなるのではなく本当に手前に浮き上がって見えるからです。

05スクロール方向非表示切替 — スクロール方向の値が下向きだとバー全体が画面外へ隠れ、上向きだと再び現れる方式

スクロール方向の値が変わった瞬間、バーの縦位置の値が画面外と元の位置の間を行き来します。本文を広く見せたい画面に向いており、VEL_HIDE90とVEL_SHOW40というしきい値の非対称さのおかげで、バーは隠れるときは慎重で、戻るときは素早く反応するからです。

スクロール方向非表示切替 — スクロール方向の値が下向きだとバー全体が画面外へ隠れ、上向きだと再び現れる方式
新しいタブで開く

05 · スクロール方向非表示切替 — スクロール方向の値が下向きだとバー全体が画面外へ隠れ、上向きだと再び現れる方式 — 計測 累積面積 39.533% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

VEL_HIDELERP_RATEtranslateY
  function onScrollFrame(scrollTop, dtSec) {
    // 計算層: 実際のscrollTopの変化量から速度(px/s)を求め、非対称のしきい値と比較する
    if (dtSec <= 0) return;
    var delta = scrollTop - lastScrollTop;
    lastScrollTop = scrollTop;
    var vel = delta / dtSec;
    if (scrollTop <= 2) {
      hiddenTarget = 0;
    } else if (vel > VEL_HIDE) {
      hiddenTarget = 1;
    } else if (vel < -VEL_SHOW) {
      hiddenTarget = 0;
    }
  }

  function render(dtSec) {
    navY += (hiddenTarget * BAR_H - navY) * (1 - Math.exp(-LERP_RATE * dtSec));
    track.style.transform = 'translateY(' + navY.toFixed(2) + 'px)';
  }

数値が語ること

05番はフレーム全体の39.53%を累積で占め、平均強度30.5で、23フレームのうち23フレーム全てで動いています。これはonScrollFrame()がhiddenTargetがそのフレームで実際に切り替わるかどうかに関わらず、scrollTopの変化量から毎フレーム実際の速度を求め続け、render()のLERP_RATE14による緩和が切り替わりのたびにnavYを数フレーム調整し続けるためで、9つの中で最も広く、最も途切れず動く項目です。

06長押し展開切替 — 押下時間の値がしきい値を超えるとその場所上の展開度の値が増え追加の場所が現れる方式

押下時間の値がしきい値を超えた瞬間、その場所上の展開度の値が0から最大に変わります。タブごとに下位選択肢を隠したナビゲーションに向いており、リングが満ちるまでのHOLD_MS650msという時間そのものが、誤操作を取り消せる猶予にもなっているからです。

長押し展開切替 — 押下時間の値がしきい値を超えるとその場所上の展開度の値が増え追加の場所が現れる方式
新しいタブで開く

06 · 長押し展開切替 — 押下時間の値がしきい値を超えるとその場所上の展開度の値が増え追加の場所が現れる方式 — 計測 累積面積 3.297% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FAN_ANGLESRING_CIRCstroke-dashoffset
  function placeActions() {
    // 計算層: FABの中心を基準に角度(FAN_ANGLES)を実際の三角関数で座標に変換する
    var fabRect = fab.getBoundingClientRect();
    var phoneRect = fab.closest('.lp__phone').getBoundingClientRect();
    var cx = fabRect.left + fabRect.width / 2 - phoneRect.left;
    var cy = fabRect.top + fabRect.height / 2 - phoneRect.top;
    actions.forEach(function (btn, i) {
      var rad = FAN_ANGLES[i] * Math.PI / 180;
      var x = cx + FAN_R * Math.cos(rad);
      var y = cy + FAN_R * Math.sin(rad);
      btn.style.left = x.toFixed(1) + 'px';
      btn.style.top = y.toFixed(1) + 'px';
    });
  }

数値が語ること

06番はフレーム全体の3.30%を累積で占め、平均強度74.2で、23フレームのうち16フレームで動いています。これはplaceActions()がリングがHOLD_MSのしきい値を超えた時だけ各アクションのx・yを再計算し、FAN_ANGLES配列(-150度・-90度・-30度)を実際のFAN_R62pxの半径に基づく座標へ変換するためで、常時かかる負荷ではなく一瞬の三角関数の計算にとどまっているからです。

07スワイプ追従切替 — 横方向の移動距離の値が増える間、強調表示の位置の値がその割合分だけ隣のタブ側へ追従する方式

移動距離の値が増える間、強調表示の位置の値がその割合の分だけ隣のタブ側へ一緒に動きます。画面を横にスワイプしてタブを切り替えるモバイルナビゲーションに向いており、強調表示が指を離した後ではなく、ドラッグしている最中からリアルタイムに追いかけてくるからです。

スワイプ追従切替 — 横方向の移動距離の値が増える間、強調表示の位置の値がその割合分だけ隣のタブ側へ追従する方式
新しいタブで開く

07 · スワイプ追従切替 — 横方向の移動距離の値が増える間、強調表示の位置の値がその割合分だけ隣のタブ側へ追従する方式 — 計測 累積面積 5.814% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_KDRAG_MStranslateX
  function onPointerDown(e) {
    human();
    dragging = true;
    settling = false;
    dragStartClientX = e.clientX;
    dragStartX = posX;
    track.setPointerCapture && e.pointerId != null && track.setPointerCapture(e.pointerId);
  }
  function onPointerMove(e) {
    if (!dragging) return;
    var dx = e.clientX - dragStartClientX;
    posX = Math.max(0, Math.min(maxX, dragStartX + dx));
    indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
  }
  function onPointerUp() {
    if (!dragging) return;
    dragging = false;
    var idx = nearestIndex(posX + indW / 2);
    setActive(idx);
    settling = true;
    settleTarget = geo[idx].left;
    vel = 0;
  }

数値が語ること

07番はフレーム全体の5.81%を累積で占め、平均強度141.6と9つの中で最も高く、23フレームのうち10フレームで動いています。これはposXがポインターが実際に押されている間か、指を離した後にgeo[idx].leftへ引き寄せる別のばね(SPRING_K240)がまだ収まっていない間にしか変わらないためで、動いた10フレームがドラッグと収束の窓に集中していて、サンプル全体に均等に広がっているわけではないからです。

08下部余白対応切替 — 画面下部の余白の値が増えるとバーの全体の高さと内側余白の値がその差分だけ一緒に増える方式

下部余白の値が増えた瞬間、バーの全体の高さの値と内側余白の値がその分だけ一緒に増えます。機種ごとに異なる下部余白に合わせるナビゲーションに向いており、バーの高さを緩和する同じLERP_RATE10がインジケーターも緩和するため、どちらかだけが先に飛び出すことがないからです。

下部余白対応切替 — 画面下部の余白の値が増えるとバーの全体の高さと内側余白の値がその差分だけ一緒に増える方式
新しいタブで開く

08 · 下部余白対応切替 — 画面下部の余白の値が増えるとバーの全体の高さと内側余白の値がその差分だけ一緒に増える方式 — 計測 累積面積 22.792% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

INSET_ONBASE_HmatchMedia
  function render() {
    // 計算層: バー全体の高さはBASE_H(コンテンツ高さ)+リアルタイムのインセット値を足して毎フレーム再計算する
    var totalH = BASE_H + insetPx;
    nav.style.height = totalH.toFixed(1) + 'px';
    inset.style.height = insetPx.toFixed(1) + 'px';
    var t = insetPx / INSET_ON;
    indicator.style.opacity = t.toFixed(3);
    indicator.style.transform = 'translateY(' + ((1 - t) * 6).toFixed(1) + 'px) scaleX(' + (0.6 + 0.4 * t).toFixed(3) + ')';
    readout.textContent = 'インセット' + Math.round(insetPx) + 'px・バー高さ' + Math.round(totalH) + 'px';
  }

  toggle.addEventListener('click', function () {
    human();
    manualOn = !manualOn;
    insetTarget = manualOn ? INSET_ON : 0;
    toggle.textContent = manualOn ? 'ホームボタン機器に切り替え' : 'ホームインジケーター機器に切り替え';
  });

数値が語ること

08番はフレーム全体の22.79%を累積で占め、平均強度89.8で、23フレームのうち17フレームで動いています。これはrender()が余白の値がINSET_ON34へ向かって、あるいは0へ戻ろうと緩和し続けている間、毎フレームBASE_H58とリアルタイムのinsetPxからtotalHを再計算し続けるためで、そのLERP_RATEの追いつきが続く限りバー自身の高さが動き続けるからです。

09モーション削減即時切替 — モーション削減設定がオンだと前の8方式の切替が全て中間段階なしに目標値へ変わる方式

モーション削減の値がオンのとき、前の8つの場所はすべて中間段階なしに目標値へ直接切り替わります。アクセシビリティ設定をオンにしたユーザーの画面に向いており、二つのパネルを並べて見せることこそが、前の8方式がモーション削減で何を失うのかを示す一番はっきりした方法だからです。

モーション削減即時切替 — モーション削減設定がオンだと前の8方式の切替が全て中間段階なしに目標値へ変わる方式
新しいタブで開く

09 · モーション削減即時切替 — モーション削減設定がオンだと前の8方式の切替が全て中間段階なしに目標値へ変わる方式 — 計測 累積面積 5.072% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DURATION_MSCYCLE_MSmatchMedia
  function goToA(idx, ts) {
    activeA = idx;
    tabsA.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
    fromXA = currentXA(ts);
    toXA = geoA[idx].left;
    transStartA = ts;
    transitioningA = true;
  }
  function currentXA(ts) {
    if (!transitioningA) return toXA;
    var t = Math.min(1, (ts - transStartA) / DURATION_MS);
    return fromXA + (toXA - fromXA) * easeOutCubic(t);
  }

数値が語ること

09番はフレーム全体の5.07%を累積で占め、平均強度133.8で、23フレームのうち12フレームで動いています。これはcurrentXA()がDURATION_MS320とeaseOutCubicの曲線で通常側のパネルをtoXAへ緩和させる一方、モーション削減側のパートナーは同じ目標値を直接代入するだけのためで、ここで動いたフレームはすべてその緩和する側から来ていて、即時に切り替わる比較相手からではないからです。

値が位置と大きさを変える基準 — 選択値・件数値・スクロール方向値

01番のspringTo()とSPRING_K210・SPRING_C19のペアが、一つの共有されたばねで位置と幅を同時に動かす仕組みを支えています。ばねが収まっていない間は毎フレーム同じ加速度の式がposXとposWの両方に反映されるため、インジケーターの移動とリサイズは常に同じ拍で終わり、二つの緩和が別々にずれていくことはありません。

06番のplaceActions()とFAN_ANGLES配列が、追加のアクションの単なる一覧を本物の扇形に変えています。[-150, -90, -30]度という各角度が実際のサインとコサインを通ってFAN_R62pxの半径に基づく座標になるため、3つのボタンはただ曲がって見える一列ではなく、FABを囲む本物の弧の上に並びます。

08番のrender()とINSET_ON34・BASE_H58のペアが、バー自身の高さを一つの固定された定数ではなく実際のセーフエリアの余白に追従させています。totalHは毎フレームBASE_Hに緩和された余白を足して再計算されるため、余白が大きい機器では見た目のパディングが増えるだけでなく、バーそのものが本当に大きくなります。

この1本だけでなく他の記事も見てみると、CSS タブ 切り替え アニメーション9選 — JS最小・CSSだけは違う軸を扱っています — こちらは下部バーの状態値が動く9通りではなく、タブバー自身が切り替わる瞬間の演出を9通り見せる記事です。フローティングメニュー デザイン9選 — ホバーで隣まで膨らむは「一列に並んだ行き先」という発想はこの記事に近いものの、固定された下部バーの代わりに、状態の変化ではなくホバーで浮き上がり形を変えるドックに置き換えています。メガメニュー css 9選 — リンクの羅列からサイトの地図へは一列という制約そのものを外し、9つの値駆動のバーではなく、サイト全体の地図を一つの開いたパネルにまとめています。この3本と上の9つのパターンを比べてみると、今の画面にとってどの軸が本当に大事なのかが一番早く分かります。

どこで壊れるか — 連続切替、余白値、モーション削減

9つのどれも自分の外から値を読み込むことはありません — すべての変化はデモ自身のクリック・ポインター・キーボードのハンドラーから来ており、これがアプリ ボトムナビゲーションをこれほど検証しやすく、決定論的にしている理由です。内側の動きが実際のばね・減衰・三角関数の計算で動いているとしても、それは変わりません。

連続切替とばねが収まる前のタップ

01番のspringTo()は、最初の切り替えがまだ収まっていない間に二回目の選択変更が来ても止めることはありません。訪問者が4つのタブを続けてすばやくタップすると、加速度の式はposX・posWが今ある場所からそのまま再スタートするため、インジケーターは一つ前に向かっていた2番目のタブに実際に着く前に、3番目のタブへ向かい始めることがあります。

余白値が割り切れないピクセルで止まる

08番のINSET_ON34という目標値はデモ上の仮定であり、実測値ではありません。実際の機器のセーフエリアの余白はその間のどんな値にもなり得ますし、LERP_RATE10がinsetPxを瞬時に切り替えず緩和させ続けるため、余白が変わり続けている間はtotalHとインジケーターのscaleXも小数のままの高さで止まります。ここでは正常な動きですが、実際の製品ではその生の浮動小数点をそのまま信じるのではなく、意図的に丸めておく価値があります。

モーション削減

01番から05番はそれぞれ読み込み時に一度だけmatchMedia('(prefers-reduced-motion: reduce)')を確認し、一致すればループの開始そのものを飛ばします — 静止した1フレームだけが描かれ、バーはそこで止まったままになります。06番から09番は同じフラグをループの起動には使わず、tick()自体は止めずにisDemo && !reduce(07番だけは&& !draggingも加わる)で自動再生の分岐だけを止めます。システムの設定が何であっても、09番の二つのパネルは訪問者自身のクリックに実際に応える必要があり、それらを並べて比べることは両方が動き続けてこそ意味を持つからです。この9項目分のソースはひとまとめのzipに収められていて、zwxer7f5さえ入れればすぐに展開できます。

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

中央浮上の拡大率を上げる(SCALE_MAX 1.14から1.28) — 拡大率を1.14から1.28に上げると、中央タブが選択された瞬間にずっと大きく浮き上がり、主要操作という印象が強まります。

長押しのしきい値時間を延ばす(HOLD_MS 650から1200) — しきい値を650msから1200msに延ばすと、リングが満ちるまでほぼ2倍かかるようになり、誤操作でメニューが開くことが減ります。

インセット反応速度を遅くする(LERP_RATE 10から4) — 平滑化速度を10から4に下げると、バーの高さが目標値に向かってずっとゆったり伸びるようになり、余白の変化が急に感じられなくなります。

バリエーション 変わるもの 変わらないもの
中央浮上の拡大率を上げる(SCALE_MAX 1.14→1.28) 04番の中央タブの浮上の大きさだけ LIFT_PX・SHADOW_BLUR_GAIN・HOLD_MS
長押しのしきい値時間を延ばす(HOLD_MS 650→1200) 06番のリングが満ちるまでの時間だけ FAN_ANGLES・RING_CIRC・FAN_R
インセット反応速度を遅くする(LERP_RATE 10→4) 08番の余白が緩和される速さだけ BASE_H・INSET_ON・matchMedia

9つのうち01番と02番の二つだけが、選択が変わるたびに各タブのボタンでaria-currentの値を'true'と'false'の間で切り替えています。これにより、スライドするインジケーターや塗りの入れ替わりを目で見ている訪問者と同じ「どのタブが選ばれているか」という情報が、スクリーンリーダーの訪問者にも伝わります。残りの7つはこの属性を持っていないため、03番から09番のどれかを実際のサイトのナビゲーションに組み込む場合は、aria-current(あるいは対になったtablist・tabのロール)を自分で追加する必要があります。9つすべてが装飾用の<div>ではなく本物の<button>要素で操作部分を作っており、どの項目もdisabled属性を一度も設定しないため、バーがどの段階にあってもタブの順序やキーボードフォーカスがずれることはありません。

FAQ

ボトムナビゲーションバーになぜ1つの汎用バーではなく9通りの転換パターンが必要なのか

下部バーを動かす値は毎回同じ意味を持つとは限らないからです。選択の番号・増え続ける件数・スクロールの方向・押し続けた時間・セーフエリアの余白は、それぞれ違う変換を必要とします。まず今動いている値が何なのか — 選択か、件数か、余白か — を先に名付けることが、コードを1行もコピーする前に、この9つのうちどれが今の画面に合うのかを教えてくれます。

06番の長押しリングはCSSの@keyframesで動いていますか、それとも計算で作られていますか

計算で作られています。ポインターが押されている間、毎フレームリングのstroke-dashoffsetは押し続けた時間をHOLD_MSと比較して直接導き出され、RING_CIRC自体も決まった数値ではなく2 * Math.PI * RING_Rから求められているため、リングの半径が変わっても同じ式がそのまま成り立ちます。

この9つの中に上部に固定されたタブバーは含まれていますか、それとも全部下部に固定されていますか

いいえ、この9つはすべて下部に固定されています。どれもコンテンツの下に置かれた固定バーを前提にしていて、上部や側面に固定されたバーは含まれていません。上でリンクしたCSS タブ 切り替え アニメーション9選 — JS最小・CSSだけは、画面の特定の辺に縛られないタブバーを集めた記事です。

Sources: MDN — Using CSS transitions · MDN — prefers-reduced-motion · MDN — env() · MDN — ARIA tablist role

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

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