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

絞り込み ui9選 — パネルスライドからチップ削除まで

絞り込み uiは、実は一つの動きではなく複数の小さな計算でできています。9つのうち1つはチップが消えた分だけ残るチップの合計幅を求め直して詰め、1つはアクティブなフィルター数の変化量でバッジを膨らませてから戻し、もう1つは2つのスライダー値の差で塗りつぶしトラックの幅を計算し直します。

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

9つの画面に表示されるカテゴリー名・チップの文言・価格はすべて架空のものです。実在の商品カタログやブランド名、外部APIへの通信は一切ありません。実際の操作に反応するのは9つ中7つで、01(パネル開閉)と06(ツリー展開、加えてresize)はclick、02(チップ削除)はチップの削除ボタンへのclick委譲、04(全解除)はボタンのclick、07(適用バー)はチェックボックス2つのchange、08(モバイルシート)は実際のpointerdown/pointermove/pointerup/pointercancel、09(価格レンジ)は2つのinputで反応します。03(件数モーフィング)・05(バッジパルス)の2つだけがクリックやドラッグのリスナーを持たず、固定配列をsetTimeoutで回すだけの自動再生専用です。

フィルターUIの解剖 — 9つの画面が計算するもの

9つの画面は、フィルターの変化をブラウザが動かせる数値に変える方法で3つに分かれます。この段階を飛ばした絞り込み uiデザインは、状態と状態の間を滑らかに動かさず、ただ切り替わるだけになりがちです。

なぜ3つの画面は個数や差分から寸法を求め直すのか

02・06・09は、いくつあるか、あるいは2つの値がどれだけ離れているかを起点に、それをピクセル寸法へ変換します。02は画面に残っているチップの幅と間隔を足し直して行の幅を求め、06は表示されている子項目の数に項目1つの高さを掛けてリストの高さを求め、09は最小値と最大値の差をトラック全長に対する比率に変換します。

なぜ3つの画面は連続値ではなく真偽の状態から位置を求めるのか

01・04・07は個数や距離を読みません。開いているかどうかという単純な真偽値だけから位置を補間します。01のパネル位置は開閉状態だけで決まり、07の適用バーの位置は「選択が1つ以上あるか」だけで決まり、04は全解除という1つの真偽値が発火した瞬間に、各チップの消える時点をその順番×固定間隔で求め直します。

なぜ3つの画面はすでに変化している数値をほぼそのまま見た目に写すのか

03・05・08は、すでに変化している数値をほとんど変換せずに見た目へ直結させます。03は表示件数を古い値から新しい値へ一段ずつ進め、05は変化量の大きさに応じてバッジを膨らませてから戻し、08はポインターの移動距離をそのままシートの高さにマッピングし、指を離した瞬間だけ最寄りの停止位置へ補間します。

画面別コード

01パネルスライドトグル — 開閉状態の値で位置を補間する基本形

パネルが開いた状態になると、画面外にあったパネルの横位置の値が画面内の位置まで補間されて入ってきて、閉じた状態になると同じ値を逆にたどって出ていきます。

パネルスライドトグル — 開閉状態の値で位置を補間する基本形
新しいタブで開く

01 · パネルスライドトグル — 開閉状態の値で位置を補間する基本形 — 計測 累積面積 41.968% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transformclassList.toggletransition

数値が語ること

23フレーム中10フレームで変化し、累積面積は41.968%、平均強度は163.3で9項目中もっとも高い値です。1つの大きな面が画面を横切って動く方が、小さな部品がいくつも動くより1ステップあたりの移動ピクセルが多くなります。パネル自身のトラック幅はmin(64vw, 200px)なので、幅の狭いスマホ画面でも別のブレークポイントなしに同じ変換式がそのまま使えます。

(function () {
  var stage = document.getElementById('stage');
  var trigger = document.getElementById('trigger');
  var panel = document.getElementById('panel');
  if (!stage || !trigger || !panel) return;

  function setOpen(open) {
    stage.classList.toggle('is-open', open);
    trigger.setAttribute('aria-expanded', String(open));
    panel.setAttribute('aria-hidden', String(!open));
  }

  trigger.addEventListener('click', function () {
    panel.classList.remove('is-demo');
    setOpen(!stage.classList.contains('is-open'));
  });
})();

02チップ削除の詰め — 残りチップ数でコンテナ幅を求め直す行

チップが1つ消えた状態になると、残っているチップの幅と間隔の値を足し直してコンテナ全体の幅を求め、その値だけ行全体が狭くなります。

チップ削除の詰め — 残りチップ数でコンテナ幅を求め直す行
新しいタブで開く

02 · チップ削除の詰め — 残りチップ数でコンテナ幅を求め直す行 — 計測 累積面積 5.569% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRecttransitiongap

数値が語ること

23フレーム中11フレームが動き、累積面積5.569%・平均強度149.5と小さめの数値です。動くのは行自体の幅だけで、周囲のページは動かないためです。recompute()は実際の削除でも自動再生の追加・削除サイクルでも同じ足し直しを行うので、検索 絞り込み uiの実装として読んでも挙動は常に一致します。

  function removeChip(chip) {
    if (!chip) return null;
    var label = chip.dataset.label || '';
    chip.remove();
    recompute();
    return label;
  }

  function addChip(label) {
    var chip = buildChip(label);
    row.appendChild(chip);
    recompute();
  }

  row.addEventListener('click', function (e) {
    var btn = e.target.closest('.chip__x');
    if (!btn) return;
    removeChip(btn.closest('.chip'));
  });

03件数モーフィング — 前の値と新しい値の間を補間して数える数字

フィルター条件が変わって件数が変化した状態になると、前の件数の値から新しい件数の値まで一定の間隔に分けて1つずつ上げ下げして表示します。

件数モーフィング — 前の値と新しい値の間を補間して数える数字
新しいタブで開く

03 · 件数モーフィング — 前の値と新しい値の間を補間して数える数字 — 計測 累積面積 1.142% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramesetPropertytextContent

数値が語ること

23フレーム中18フレームが動くのに累積面積はわずか1.142%です。文字の数字が書き換わるだけの動きはパネルのスライドよりずっと少ないピクセルしか動かさないため、面積の小ささはこの画面の構造がそうなっているだけで弱さではありません。VALUESは4つの固定件数を順に巡り、各ステップでは前の件数と次の件数の間をイージングした位置を四捨五入して表示します。

  function animate(to, duration) {
    var start = null;
    function frame(ts) {
      if (start === null) start = ts;
      var t = Math.min(1, (ts - start) / duration);
      var value = Math.round(from + (to - from) * ease(t));
      el.textContent = value + '件';
      el.style.setProperty('--pulse', (1 - t).toFixed(3));
      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        from = to;
        setTimeout(step, HOLD_MS);
      }
    }
    requestAnimationFrame(frame);
  }

  function step() {
    idx = (idx + 1) % VALUES.length;
    animate(VALUES[idx], STEP_MS);
  }

  setTimeout(step, HOLD_MS);

04全解除カスケード — 順番の値に間隔を掛けて順に消えるチップ

全解除の状態になると、各チップが消え始める時点をその順番の値に固定間隔を掛けて求め直し、前から順番に消えていきます。

全解除カスケード — 順番の値に間隔を掛けて順に消えるチップ
新しいタブで開く

04 · 全解除カスケード — 順番の値に間隔を掛けて順に消えるチップ — 計測 累積面積 4.054% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

animation-delayclassList.removeopacity

数値が語ること

23フレーム中12フレームが動き、累積面積4.054%・平均強度74.0と9項目中2番目に低い強度です。各チップはその場でフェードして縮むだけで、遠くまで移動しないためです。出荷時のSTAGGER_MSは90msで、後述のバリエーションでは200msにした場合を扱います。

(function () {
  var STAGGER_MS = 90;
  var chips = document.getElementById('chips');
  var clearBtn = document.getElementById('clearBtn');
  if (!chips || !clearBtn) return;

  clearBtn.addEventListener('click', function () {
    chips.classList.remove('is-demo');
    var items = chips.querySelectorAll('.chip');
    items.forEach(function (chip, i) {
      setTimeout(function () {
        chip.classList.remove('is-active');
      }, i * STAGGER_MS);
    });
  });
})();

05アクティブ数バッジパルス — 増減の値だけ膨らんでから戻る数字バッジ

アクティブなフィルター数が変わった状態になると、前の値と新しい値の差の大きさだけバッジが膨らみ、差が大きいほど大きく膨らんでから元の大きさに戻ります。

アクティブ数バッジパルス — 増減の値だけ膨らんでから戻る数字バッジ
新しいタブで開く

05 · アクティブ数バッジパルス — 増減の値だけ膨らんでから戻る数字バッジ — 計測 累積面積 0.34% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transformscale(transitionend

数値が語ること

累積面積0.34%・平均強度88.0・23フレーム中14フレームと9項目中もっとも小さな数値ですが、これは欠陥ではなく構造上そうなるものです。1桁の数字バッジはチップの行やパネルより動かせるピクセル自体が少ないためです。膨らんでから戻る動きはtransitionendリスナーだけで制御されているので、バックグラウンドでスロットルされたタブでも膨らんだ途中で止まらず正しい大きさに落ち着きます。

  function grow() {
    idx = (idx + 1) % VALUES.length;
    badge.textContent = String(VALUES[idx]);
    badge.style.transform = 'scale(1.4)';
  }

  badge.addEventListener('transitionend', function (e) {
    if (e.propertyName !== 'transform') return;
    if (badge.style.transform === 'scale(1.4)') {
      badge.style.transform = 'scale(1)';
    } else {
      setTimeout(grow, HOLD_MS);
    }
  });

06カテゴリーツリー展開 — 子の数で高さを求め直すリスト

カテゴリーが展開された状態になると、中に入っている子項目の数に項目1つの高さの値を掛けてリスト全体の高さを求め直し、その値まで伸びます。

カテゴリーツリー展開 — 子の数で高さを求め直すリスト
新しいタブで開く

06 · カテゴリーツリー展開 — 子の数で高さを求め直すリスト — 計測 累積面積 16.82% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollHeightsetPropertyaria-expanded

数値が語ること

23フレーム中11フレームが動き、累積面積16.82%・平均強度140.5で、02のおよそ4倍の面積です。行が1本狭くなるだけの02よりリスト全体の高さの方が広い面を占めるためです。measure()は展開時とウィンドウのresizeの両方でlist.scrollHeightを--tree-heightカスタムプロパティに書き込むので、回転したタブレットでも古いキャッシュ値ではなく測り直した高さになります。

(function () {
  var toggle = document.getElementById('toggle');
  var list = document.getElementById('list');
  if (!toggle || !list) return;

  function measure() {
    list.style.setProperty('--tree-height', list.scrollHeight + 'px');
  }
  measure();

  toggle.addEventListener('click', function () {
    list.classList.remove('is-demo');
    measure();
    var open = list.classList.toggle('is-open');
    toggle.setAttribute('aria-expanded', String(open));
  });

  window.addEventListener('resize', measure);
})();

07適用バーのスティッキー登場 — 選択有無の値で位置を求め直す下部バー

選択されたフィルターが1つ以上ある状態になると、画面下に隠れていた適用バーの縦位置の値が画面内へ補間されて現れ、選択がない状態になると再び隠れます。

適用バーのスティッキー登場 — 選択有無の値で位置を求め直す下部バー
新しいタブで開く

07 · 適用バーのスティッキー登場 — 選択有無の値で位置を求め直す下部バー — 計測 累積面積 19.0% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transformtranslateY(classList.toggle

数値が語ること

23フレーム中12フレームが動き、累積面積19.0%・平均強度130.7で、自分のトラックの全長を1つの大きな面が動く点で01のパネルに近い数値です。sync()はチェックボックスのchangeのたびに実行され、確認するのは「どれか1つでも選択されているか」だけなので、フィルターの選択肢を10個目に増やしても表示ロジック自体は変更不要です。

(function () {
  var stage = document.getElementById('stage');
  var bar = document.getElementById('bar');
  var inputs = document.querySelectorAll('.option__input');
  if (!stage || !bar || !inputs.length) return;

  function sync() {
    bar.classList.remove('is-demo');
    var any = Array.prototype.some.call(inputs, function (input) {
      return input.checked;
    });
    stage.classList.toggle('has-selection', any);
  }

  inputs.forEach(function (input) {
    input.addEventListener('change', sync);
  });
})();

08モバイルシートドラッグ — 移動距離の値を高さにマッピングするボトムシート

シートを引いている状態になると、ポインターが移動した距離の値をシートの高さの値にそのままマッピングして追従し、手を離した状態になると近い停止位置の値へ補間されます。

モバイルシートドラッグ — 移動距離の値を高さにマッピングするボトムシート
新しいタブで開く

08 · モバイルシートドラッグ — 移動距離の値を高さにマッピングするボトムシート — 計測 累積面積 44.842% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointerdownclamp(transform

数値が語ること

23フレーム全部で動く唯一の項目で、累積面積は9項目中もっとも高い44.842%ですが、平均強度は9項目中もっとも低い30.3です。ドラッグ距離を高さへ1対1でマッピングすると、大きな段差で数回飛ぶのではなくほぼ毎フレーム少しずつ変化するためです。これはこのセットの中で日付 絞り込み uiにも通じるモバイル向けパターンで、MAX_DRAGがシートの移動量を130pxまでに固定しているので、素早く弾くように引いても設計した範囲を超えて動くことはありません。


  function setDrag(px) {
    sheet.style.setProperty('--drag', Math.max(0, Math.min(MAX_DRAG, px)) + 'px');
  }

  handle.addEventListener('pointerdown', function (e) {
    dragging = true;
    startY = e.clientY;
    sheet.classList.remove('is-demo');
    sheet.classList.add('is-dragging');
    handle.setPointerCapture(e.pointerId);
  });

  handle.addEventListener('pointermove', function (e) {
    if (!dragging) return;
    setDrag(e.clientY - startY);
  });

  function release() {
    if (!dragging) return;
    dragging = false;
    sheet.classList.remove('is-dragging');
    setDrag(0);
  }

09価格レンジ塗りつぶし — 2つの入力値の差でトラック幅を求めるスライダー

最小・最大の2つのつまみの値が変わった状態になると、その2つの値の差をトラック全長に対する比率として計算し、塗りつぶし区間の開始と終了の位置を描き直します。

価格レンジ塗りつぶし — 2つの入力値の差でトラック幅を求めるスライダー
新しいタブで開く

09 · 価格レンジ塗りつぶし — 2つの入力値の差でトラック幅を求めるスライダー — 計測 累積面積 1.531% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

input[type=range]calc(setProperty

数値が語ること

23フレーム中11フレームが動き、累積面積1.531%・平均強度160.4で9項目中2番目に高い強度です。塗りつぶし区間自体が細いので、1フレームごとの変化が少ないピクセルに集中するためです。自動再生のコメントは、本物のinputリスナーからシード済みの再生シーケンスへ切り替わる箇所をそのまま示しています。

  minInput.addEventListener('input', update);
  maxInput.addEventListener('input', update);
  update();

  // 自動再生専用: 本物のinputイベントの代わりに固定シード値のペアを順番に流す
  var SEQ = [[20, 70], [5, 40], [55, 95], [20, 70]];
  var idx = 0;
  (function step() {
    setTimeout(function () {
      idx = (idx + 1) % SEQ.length;
      minInput.value = String(SEQ[idx][0]);
      maxInput.value = String(SEQ[idx][1]);
      update();
      step();
    }, 550);
  })();

数値が画面を変える場所 — 残り幅・アクティブ数・範囲の差

上の9つの数値のうち3つは、見た目の細部ではなく画面全体の形そのものを決めています。この3つを読み違えると、動きだけでなくレイアウトそのものが崩れます。

なぜ残り幅は引き算ではなく足し直しが必要なのか

02のコンテナは、チップが1つ消えたときに単純にそのチップの幅を引くだけでは済みません。残ったチップ同士の間隔の値も、間に挟まるチップが1つ減ったことで変わるためです。recompute()は削除のたびに残っているチップそれぞれのoffsetWidthと行のgapの値を最初から足し直しており、引き算を積み重ねる方式だと数回の操作でずれが蓄積してしまいます。

なぜアクティブ数はバッジの文字だけでなく膨らむ量も動かすのか

05はバッジの数字を差し替えるだけではありません。膨らむ量そのものが変化量の大きさに比例しており、2から3への変化は2から9への変化より小さく膨らみます。膨らむ量を固定値にしたタグ 絞り込み uiコンポーネントでは、この差はまるごと失われてしまいます。

なぜ範囲の差はトラック全長に対する比率でなければならないのか

09の塗りつぶし区間は生のピクセル値では位置を決められません。トラック自体の幅がmin(72vw, 220px)でビューポートによって変わるためです。最小値と最大値の差はトラックの現在の幅に対する割合に変換されるので、デスクトップで220pxになってもスマホで狭い値になっても塗りつぶしの位置は正しいままです。

どこで壊れるか — 連続解除・モバイルのドラッグ・低コントラスト

このセットが1つの開閉エッジを中心に組んだ9画面のサイドバードロワーと異なるのは範囲の広さです。あちらは4つのエッジのうち1つからパネルを動かすだけですが、こちらはチップの行・ツリー・スライダー・シートにまたがり、それぞれ異なる種類の寸法を求め直しています。項目04にある順番×固定間隔というカスケードの考え方は、複数の削除可能なチップではなく1つの選択肢だけを対象に形を変えて、選択肢の間をハイライトが滑る9画面のセグメントコントロールにも現れます。このセットの9画面すべての実装(バニラ・React両方)は、この記事のzip内でmbfmp3s6の先にあります。

解除して選び直す操作がカスケードの途中で重なるとどうなるか

04のsetTimeoutはclearBtnが発火した瞬間の各チップの順番に紐付いています。カスケードの途中でフィルターを選び直すと、前のクリックによるフェードがまだ終わっていないチップと、新しいレイアウトが計算する消える順番が重なってしまうため、実運用では2回目のclickで保留中のタイマーをキャンセルする処理が必要です。

モバイルのドラッグにMAX_DRAG以外に必要なもの

08はドラッグ量を130pxで止めますが、実機ではハンドルにtouch-action: noneを指定しないと、縦方向のドラッグの下でブラウザ自身がページをスクロールしようとしてしまいます。またpointercancelでもpointerupと同じrelease()の後始末を実行しないと、着信や画面ジェスチャーで中断されたドラッグがis-draggingのまま固まってしまいます。

低コントラストが絞り込み uiに与える影響

チップの削除×、ツリーの展開用の矢印、スライダーのつまみはどれも小さな操作対象で、周りのラベルよりも自分自身の背景とのコントラストに依存しています。本文の文字色だけを確認するトークン監査では、こうした操作対象の輪郭が周囲のチップやトラックの色に沈んでいても通過してしまいます。

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

バリエーション 変わる値 トレードオフ
チップ行の詰めにかかる時間 項目02の$dur-baseトークンを300msから650msへ 300msだとチップが消えた瞬間ほぼ同時に行が詰まり硬い印象になり、650msだと残ったチップが滑らかに寄っていくが、何個も連続で消すと行がずっと追いついていないように感じられる
全解除カスケードの間隔 項目04のSTAGGER_MSを90msから200msへ 90msだとチップがほぼ同時に消えて左から右への順番がほとんど分からず、200msだと1つずつ消える順番がはっきり見えるが、チップが多いと解除が終わるまでかなり長く感じる
ツリー展開のイージング曲線 項目06の$ease-springトークンをオーバーシュートのあるcubic-bezier(0.34, 1.56, 0.64, 1)へ springは最終的な高さへ穏やかに落ち着き、オーバーシュートのある曲線は最終的な高さを一度超えてから戻るので、下にまだ子項目が読み込まれている感覚を伝える

このzipには650ms・200ms・オーバーシュート曲線の版が入っています。

reduced motionとスクリーンリーダーが同じ計算値から受け取るもの

9つすべてがprefers-reduced-motionを確認し、該当する場合は補間そのものを省いて最終値へ瞬時に切り替えます。開いた幅もバッジの大きさも塗りつぶしの位置も最終値は変わらないため、reduced motionの読者も正しい状態にたどり着きます。01のトリガーと06のトグルのaria-expanded、01のパネルのaria-hidden、03の件数表示のrole="status"は、アニメーションを動かしているのと同じ数値をスクリーンリーダーへの通知にも使っています。

FAQ

9つの画面は実在の商品データや価格を読み込んでいるのか

いいえ。9つに表示されるカテゴリー名・チップの文言・価格はすべてマークアップやVALUES・SEQ配列に直接書き込まれた固定の架空の値です。fetch通信も実在の在庫も、実際の価格APIも一切ありません。

9つのうち本物のクリック・ドラッグ・チェックボックス・入力に反応するのはどれか

7つが反応します。01・02・04・06は実際のclick、07はチェックボックスのchange、08は実際のpointerdown/pointermove/pointerup、09はinputに反応します。03・05の2つだけがクリックやドラッグ、ポインター、入力のリスナーを持たず、固定のsetTimeoutループだけで動いています。

この絞り込み uiデザインはマウスやタッチなしキーボードだけで操作できるのか

実際のリスナーを持つ7つのうち6つはそのままキーボードで操作できます。01のトリガー・02のチップ削除ボタン・04の解除ボタン・06のトグルはすべてネイティブのbutton要素、07はcheckbox、09は2つのinput[type=range]で、ポインターだけでなくEnter/Spaceや矢印キーでもリスナーが発火します。08のドラッグハンドルだけは、出荷状態ではポインター操作しか用意されていないため、実運用では矢印キーによる操作も追加で用意する必要があります。

Sources: MDN — CSS custom properties · MDN — Element.getBoundingClientRect · MDN — prefers-reduced-motion · MDN — PointerEvent

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

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