きっかけ: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は状態を「反映するだけ」の存在にする
派手な機能を増やさなくても、「壊して、また作る」だけで体験は大きく変わる——というのが、今回の話の結論だった。