Next.js ブログのレスポンシブ対応 — backdrop-filter の罠で全部壊れた話
目次
課題
このブログをスマートフォンで初めて開いたとき、すぐに問題が目についた。ナビゲーションリンクが横に溢れている。パディングがデスクトップサイズのまま。ボタンが小さすぎてタップしにくい。見出しが不釣り合いに大きい。
修正は4つの領域に及んだ:ハンバーガーメニュー、レスポンシブスペーシング、レスポンシブタイポグラフィ、WCAG 準拠のタッチターゲット。すべて Tailwind CSS のユーティリティクラスと標準の React hooks で実装し、外部ライブラリは使っていない。
だが、この作業で最も学びが大きかったのはレスポンシブデザインとは無関係だった。ドロワーの実装を静かに壊した CSS 仕様のエッジケースの話だ。
backdrop-filter の罠
誰かに先に教えてほしかったと思う落とし穴だ。デバッグに数時間かかった。モバイルメニューを作らなくても、知っておく価値がある。
前提
ブログのヘッダーは sticky top-0 z-50 に backdrop-blur-sm(すりガラス効果)を適用している。MobileNav コンポーネントはこのヘッダーの子要素。ドロワーを fixed で配置し、ヘッダーの下からビューポート下端まで覆うつもりだった。
問題 1: z-index スタッキングコンテキスト
最初の実装ではオーバーレイを fixed inset-0 z-40 にした。2つの問題が発生:
- オーバーレイがヘッダー領域まで覆ってしまう —
inset-0はビューポート全体を対象にするため、ヘッダーのブラーの上にさらにブラーが重なり表示が崩れた - ハンバーガーボタンが操作不能になる — ボタンの z-index は実質 0 で、z-40 のオーバーレイの下に隠れてしまった
オーバーレイの範囲を top-14 right-0 bottom-0 left-0 に変更し、ボタンに relative z-50 を追加した。
問題 2: 本当の驚き
ドロワーの縦幅がヘッダーの高さ(約 57px)と同じになり、ビューポート下端まで伸びない。メニューがほとんど見えない。
原因はヘッダーの backdrop-blur-sm だった。これは backdrop-filter: blur(4px) を生成するが、CSS 仕様上 backdrop-filter を持つ要素は fixed 子要素の包含ブロック(containing block)になる。 つまり fixed top-14 bottom-0 は「ビューポートの上端から 56px〜下端」ではなく、「ヘッダーの上端から 56px〜ヘッダーの下端」として解釈される。ドロワーの高さは実質ゼロだ。
この挙動は transform、perspective、filter、backdrop-filter、will-change で発生する。仕様には書いてあるが、実践的なチュートリアルではほとんど言及されない。
解決策: createPortal
最終的に、オーバーレイとドロワーをヘッダーの DOM ツリーの外にレンダリングする方式に切り替えた:
import { createPortal } from "react-dom";
export function MobileNav({ locale, t }: MobileNavProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="md:hidden">
<button
className="relative z-50 inline-flex h-9 w-9 ..."
aria-expanded={isOpen}
>
{/* ハンバーガーアイコン */}
</button>
{isOpen &&
createPortal(
<>
<div
className="fixed top-14 right-0 bottom-0 left-0 z-40 bg-black/50"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<nav className="fixed top-14 right-0 bottom-0 z-50 w-3/4 max-w-xs bg-background p-6 shadow-xl">
{/* メニュー内容 */}
</nav>
</>,
document.body,
)}
</div>
);
}document.body にポータルすることで、ドロワーの fixed 配置がビューポートに対して解決され、ヘッダーの backdrop-filter 包含ブロックを完全にバイパスできる。
教訓: sticky ヘッダーの中で fixed 配置が期待通りに動かないとき、親要素の backdrop-filter、transform、filter を確認すること。あるいは createPortal で問題ごと回避するのが確実だ。
ハンバーガーメニューのアーキテクチャ
Header コンポーネントはサーバーコンポーネントのまま維持。インタラクティブなモバイルナビゲーションだけを "use client" コンポーネントとして分離した:
Header (Server Component)
├── Desktop Nav (hidden md:flex) — 既存
└── MobileNav (Client Component, md:hidden) — 新規
├── ハンバーガーボタン
├── オーバーレイ(body にポータル)
└── ドロワー(body にポータル)
辞書(getDictionary(locale) の結果)は MobileNav に props で渡し、クライアントコンポーネントが i18n ライブラリに依存しないようにしている。
主な挙動:ドロワー展開時にボディスクロールをロック、Escape キーでクローズ、各リンクの onClick で setIsOpen(false) を呼ぶ。当初 usePathname() の変化を useEffect で監視する方式を実装したが、React 19 の react-hooks/set-state-in-effect ルールに抵触した。明示的な onClick ハンドラの方がクリーンだ。
レスポンシブスペーシング・タイポグラフィ・タッチターゲット
これらは地味だが、合わせると効果は大きい。
スペーシング — すべてのページコンテナを py-12 から py-6 md:py-12 に変更。11ファイルにわたってモバイルの縦パディングを半減させ、コンテンツの可視領域を大幅に改善した。
タイポグラフィ — .prose 内の見出しサイズを CSS @media クエリでモバイル時に縮小(例: h1 を 2rem → 1.625rem)。コードブロックのフォントサイズも 0.8125rem → 0.875rem で段階を付けた。
タッチターゲット — WCAG 2.5.5 は最低 44x44 CSS ピクセルを推奨。ソーシャルシェアボタン、ページネーション、フッターアイコンをモバイルで h-11 w-11(44px)に拡大し、md で元のサイズに戻す:
// ソーシャルシェアボタン — モバイル 44px、デスクトップ 36px
"inline-flex h-11 w-11 ... md:h-9 md:w-9";Viewport — Next.js の Viewport export に maximumScale: 5 を追加。maximumScale: 1 はピンチズームを無効にし、WCAG のアクセシビリティガイドラインに違反する。
まとめ
backdrop-filterはfixed子要素の包含ブロックを作る — この記事で最も覚えておくべきこと。CSS 仕様にあるがほとんど議論されない。transform、filter、will-changeも同様。親要素の中でfixed配置が壊れたら、createPortalが確実な回避策だ。- Server Component をデフォルトに —
"use client"が必要だったのはハンバーガーメニューだけ。ヘッダー、ナビリンク、すべてのページレイアウトはサーバーレンダリングのまま。 - モバイルファーストのスペーシングは積み重ねが効く —
py-6 md:py-12はファイルあたり些細な変更だが、全ページに適用するとモバイルの読書体験が目に見えて改善する。 - WCAG タッチターゲットは簡単に実装できる —
h-11 w-11 md:h-9 md:w-9でモバイル 44px を確保しつつ、デスクトップデザインは変えない。
