スマホ画面がフィルターボタンで埋め尽くされた日 — ドロワーUIで解決した話

きっかけ:PCでは快適、スマホでは地獄

あるECサイトのコレクションページ(商品一覧)を作っていた。

要件はシンプルだった。商品を「カテゴリ」「ショップ」「アイテムタイプ」で絞り込めて、「並び順」も切り替えられる。それぞれボタン形式で並べた。

PCで見ると、上部に横一列でフィルターボタンが並ぶ、よくあるECサイトの見た目になった。問題ない。

ところがスマホで開いた瞬間、事態が一変した。

  • カテゴリボタン:6個
  • ショップボタン:4個
  • アイテムタイプボタン:7個
  • 並び順ボタン:5個

合計22個のボタンが、画面幅に収まらず折り返しを繰り返し、縦に何段も積み重なる。スクロールしてもスクロールしても商品が出てこない。「コレクションページ」という名前なのに、肝心のコレクション(商品)が画面外に追い出されている状態だった。

PC用に最適化したUIを、そのままレスポンシブで縮めただけでは破綻する——典型的なパターンにはまっていた。

設計判断:PC側は変えない、モバイルだけ別ルートを作る

最初に考えたのは「ボタンを全部小さくする」「アコーディオンで折りたたむ」といった延命措置だったが、どれも本質的な解決にならない。情報量自体が多すぎるのだから、レイアウトを小さくしても情報の密度は変わらない。

行き着いた答えはシンプルだった。

モバイルでは、フィルター・ソートのUIを画面から完全に追い出す。普段は見せず、必要なときだけ呼び出す。

具体的には:

  • PC(769px以上):従来どおり、上部に全ボタンを横並びで常時表示
  • モバイル(768px以下):上部には「FILTER / SORT」という1個のトリガーボタンだけを置く
  • トリガーをタップすると、画面下からドロワー(スライドアップするパネル)が出てきて、その中に全フィルター項目が並ぶ

PC用のマークアップとロジックは一切変更せず、メディアクエリで表示・非表示を切り替えるだけにした。CSSでdisplay:noneを切り替えるシンプルな分岐だが、これにより「PCでの使い心地を犠牲にせずモバイル専用の体験を追加する」という両立ができた。

@media (max-width: 768px) {
  .nk-controls { display: none; } /* PC用バーを隠す */
  .nk-mob-bar { display: flex; } /* モバイル用トリガーを表示 */
}

ドロワー自体は、transform: translateY(100%) で画面外に待機させておき、開くときに translateY(0) に変える。これだけで滑らかなスライドアップアニメーションになる。

.nkc-filter-drawer {
  position: fixed;
  bottom: 0; left: 0; right: 0;
  transform: translateY(100%);
  transition: transform .32s cubic-bezier(.25,.46,.45,.94);
  max-height: 80vh;
  overflow-y: auto;
}
.nkc-filter-drawer.open { transform: translateY(0); }

ハマりポイント①:同じIDが、別の場所で衝突していた

実装中、最も時間を取られたのがこれだった。

ドロワーを開く処理を書いて、ボタンの選択状態が正しく切り替わるはずなのに、なぜかグローバルナビゲーション側のドロワーが誤作動する。コンソールにエラーは出ない。ただ、意図しない要素がクラスを付け替えられている。

原因は、document.getElementById() で要素を取得していたコードが、サイト内の別の場所で使われている同名のIDを先に掴んでしまっていたことだった。

getElementById は文字どおり「IDで要素を1つ取得する」関数であり、HTML仕様上IDはページ内で一意であるべきだが、実際には別々の機能を別々のタイミングで実装していくと、同じID名がうっかり再利用されてしまうことがある。ブラウザは重複したIDがあってもエラーを出さず、最初に見つかった要素を黙って返す。そのため、バグの発生源がコンソールに一切現れず、見た目の異常からしか気づけない。

今回の対策は、命名規則を機能単位で明確に分離することだった。

  • フィルター・ソート機能のID/クラスには nk- または nkc-filter- というプレフィックスを必ず付ける
  • 商品登録フォームなど別機能には nkc-add- という別系統のプレフィックスを付ける
  • グローバルナビ側の要素とは命名の語幹自体を被らせない
// Before(衝突の温床)
document.getElementById('drawer')

// After(機能ごとにスコープを明示)
document.getElementById('nkc-filter-drawer')
document.getElementById('nkc-add-drawer')

地味な話だが、IDの命名規則は「後から増える機能とぶつからないように設計する」という意味で、見た目に出ない設計判断の中でも重要度が高い部類だと感じた。

ハマりポイント②:背景スクロールが、片方の環境だけ止まらない

ドロワーを開いている間、背後のページがスクロールできてしまうと、ユーザーは「今どこを触っているのか」がわからなくなる。これを防ぐには、ドロワー表示中は背景のスクロールを固定する必要がある。

最初に書いたコードはこうだった。

function openDrawer() {
  document.body.style.overflow = 'hidden';
}

ローカル環境とブラウザの大半では問題なく動いた。ところが、本番のホスティング環境で確認すると、一部のケースで背景がわずかにスクロールしてしまう現象が再現した。

bodyにoverflow:hiddenを当てるだけでは効かないケースがある、というのは知識として知っていたが、実際に踏むとやはり厄介だった。原因は、スクロールの実体が<body>ではなく<html>document.documentElement)側にあるケースが存在することだった。ブラウザやテーマのCSS設計(html, bodyどちらに高さやoverflowの基準を持たせているか)によって、スクロールを制御している要素が変わる。

対策は、両方に同時にoverflow: hiddenをかけることだった。

function openDrawer() {
  document.documentElement.style.overflow = 'hidden';
  document.body.style.overflow = 'hidden';
}
function closeDrawer() {
  document.documentElement.style.overflow = '';
  document.body.style.overflow = '';
}

どちらかだけでは塞ぎきれない環境がある、という前提に立って両方止めておくのが安全だった。1行追加するだけの修正だが、これに気づくまでは「なぜ自分の環境では再現しないのに本番で起きるのか」というところで時間を使った。環境依存のバグは、まず「スクロールの基準点がどの要素か」を疑ってよい、という学びだった。

ハマりポイント③:PCとモバイル、2つのUIの状態をどう一致させるか

ここまでで「PC用ボタン」と「モバイル用ドロワー」という、見た目も構造も別々のUIが2つ存在することになった。問題は、ユーザーがPCの画面幅を変えてモバイル表示に切り替えたとき(あるいは逆)、選択していたフィルターの状態が食い違ってはいけない、という点だった。

これを「PC側のボタンを操作したらモバイル側のボタンも書き換える」「モバイル側を操作したらPC側も書き換える」という双方向の同期処理で解決した。ただし、本当に状態の中心になっているのは、どちらのDOMでもない。

let state = { genre: 'all', shop: 'all', type: 'all', sort: 'default' };

このJavaScriptオブジェクト1つだけが「今、何が選ばれているか」の正解(Single Source of Truth)であり、PC側のボタンの見た目も、モバイル側ドロワーの見た目も、すべてこのstateを反映した結果にすぎない、という設計にした。

function bindPC(id, key, attr) {
  document.getElementById(id).addEventListener('click', e => {
    const btn = e.target.closest('.nk-filter-btn, .nk-sort-btn');
    if (!btn) return;
    state[key] = btn.dataset[attr]; // ① 状態を更新
    syncDrawerUI(key, attr); // ② ドロワー側の見た目を状態に合わせる
    applyFilter(); // ③ 状態に基づいて商品を絞り込む
  });
}

UIをどちらか一方が「親」になって他方を直接書き換える設計にすると、双方向で書き換えが連鎖し、いずれ「PC側を直したらモバイル側が崩れる」というバグの温床になる。stateという単一の中心を置き、PC・モバイルどちらの操作も「stateを書き換える→stateを見てUIを再描画する」という一方通行の流れに統一したことで、見た目が2つあっても管理は1つで済むようになった。

できあがったもの

最終的に、PCではこれまでと同じ操作感を保ったまま、モバイルでは画面上部に小さな「FILTER / SORT」ボタンが1つだけ残る形になった。タップすると下からドロワーが立ち上がり、フィルターを選んで「APPLY FILTER」を押すと、商品一覧がその場で絞り込まれて表示される。

22個あったボタンは、見える場所では1個になった。情報量自体は減らしていない——ただ、「常に見えている必要があるか」を機能ごとに問い直しただけだ。

まとめ

  • スマホでUIが破綻する原因は、多くの場合「PC用の情報量をそのまま縮小している」こと。情報を減らすのではなく、見せるタイミングを分けるだけで解決することがある
  • idの衝突はコンソールにエラーが出ないため発見が遅れやすい。機能単位でプレフィックスを切るのは地味だが効果が大きい
  • 背景スクロール固定はbodyだけでなくhtml(documentElement)も止めておくと安全
  • 見た目が複数あるUIほど、状態を持つ場所は1つに絞る。各UIは状態を「反映するだけ」の存在にする

派手な機能を増やさなくても、「壊して、また作る」だけで体験は大きく変わる——というのが、今回の話の結論だった。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です