markdown
はじめに (対象読者・この記事でわかること)
この記事は、Webフロントエンドの開発に携わっているエンジニアや、JavaScript と CSS で UI コンポーネントを自作している方を対象としています。
特に、position:fixed を用いたモーダルウィンドウを実装した際に、画面幅が狭いデバイスやウィンドウサイズが小さいと上部がはみ出し、スクロールできなくなる現象に悩んでいる方に向けた内容です。
本記事を読むことで、以下ができるようになります。
- 何故モーダルが「見切れる」状態になるのか、ブラウザのレイアウト計算の仕組みを理解する
- CSS と JavaScript を組み合わせて、画面サイズに応じた安全な固定モーダルを実装する手順を習得する
- 同様の問題に直面した際のデバッグポイントと、よくある落とし穴を回避するコツを身につける
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- HTML と CSS の基本的な知識(特に
position、overflow、vh、calc()など) - JavaScript の基本操作と DOM 操作(
addEventListener、styleプロパティの変更)
モーダルが見切れる背景と根本原因
position:fixed は「ビューポートに対して固定」されるため、ウィンドウサイズが変化しても要素の位置は変わりません。
しかし、次の二つが重なると「上部が見切れる」問題が顕在化します。
-
ビューポートの高さがモーダルの高さより小さい
height: 100vhで全画面を占めようとしても、モーダル内部に余白やヘッダーがあり、実際のコンテンツ高さが100vhを超えるとビューポート外にはみ出します。 -
overflow: hiddenが親要素やbodyに設定されている
多くのモーダル実装で背景スクロールを止めるためにbody { overflow: hidden; }を付与しますが、これによりモーダル自体がスクロールできなくなります。結果として、はみ出した領域はユーザーに見えません。
この二つが組み合わさると、「モーダルの上部が切れ、スクロールできない」という状態が発生します。解決策は、ビューポートの高さを超えた場合にモーダル内部でスクロールさせ、かつ背景スクロールの制御を適切に行うことです。
具体的な実装方法と手順
以下では、CSS のみでできる基本形と、JavaScript で動的に高さ・スクロールを調整する拡張形の二段階アプローチを示します。
ステップ1:CSS だけで安全なモーダルを作る
Html<!-- index.html --> <div class="modal-backdrop" id="modalBackdrop"> <div class="modal" role="dialog" aria-modal="true"> <header class="modal-header"> <h2>モーダルタイトル</h2> <button class="close-btn" aria-label="閉じる">✕</button> </header> <section class="modal-body"> <p>ここに長文コンテンツが入ります…(例として 2000px のダミーテキスト)</p> </section> <footer class="modal-footer"> <button class="action-btn">OK</button> </footer> </div> </div>
Css/* style.css */ .modal-backdrop { position: fixed; inset: 0; /* top, right, bottom, left = 0 */ background: rgba(0,0,0,0.4); display: flex; justify-content: center; align-items: center; overflow: hidden; /* 背景スクロール禁止 */ z-index: 1000; } .modal { max-height: 90vh; /* ビューポートの 90% までに制限 */ width: 90%; max-width: 500px; background: #fff; border-radius: 8px; display: flex; flex-direction: column; } /* ヘッダー・フッターは固定・高さ固定 */ .modal-header, .modal-footer { flex: 0 0 auto; padding: 1rem; background: #f5f5f5; } /* ボディは残りのスペースでスクロールさせる */ .modal-body { flex: 1 1 auto; padding: 1rem; overflow-y: auto; /* 必要に応じて内部スクロール */ } /* 小さな画面での微調整 */ @media (max-height: 500px) { .modal { max-height: 95vh; } }
ポイント解説
max-height: 90vhとflexレイアウトで、モーダル全体がビューポートを超えないように制限。modal-bodyにoverflow-y: autoを付与し、内部がはみ出したらスクロールできるようにする。modal-backdropのoverflow: hiddenは背景スクロール防止のためだけに使用し、モーダル自体はflexによって高さ調整されるのでスクロールは阻害されません。
この構造だけで、画面が狭くてもモーダルは「はみ出さず、内部でスクロール」できるようになります。
ステップ2:JavaScript で動的に高さを補正し、iOS のビューポートバグに対応
モーダルを表示するタイミングで 実際のコンテンツ高さ を測定し、必要に応じて max-height を上書きするロジックを追加します。
Js// modal.js const backdrop = document.getElementById('modalBackdrop'); const modal = backdrop.querySelector('.modal'); const closeBtn = backdrop.querySelector('.close-btn'); function openModal() { // 背景スクロール禁止(iOS の場合は position: fixed を使うことも検討) document.body.style.overflow = 'hidden'; // ビューポート高さを取得(iOS のアドレスバー込み/除外対策) const vh = window.innerHeight * 0.01; document.documentElement.style.setProperty('--vh', `${vh}px`); // モーダルの最大高さを再計算 const maxHeight = Math.min(modal.scrollHeight, window.innerHeight * 0.9); modal.style.maxHeight = `${maxHeight}px`; backdrop.style.display = 'flex'; } function closeModal() { document.body.style.overflow = ''; backdrop.style.display = 'none'; } // クリックで閉じる closeBtn.addEventListener('click', closeModal); backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeModal(); }); // キーボード操作(Esc で閉じる)も実装しておくとアクセシビリティ向上 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && backdrop.style.display === 'flex') { closeModal(); } }); // 初回ロード時は非表示にしておく backdrop.style.display = 'none';
実装ポイント
-
--vhカスタムプロパティ
iOS Safari はアドレスバーが表示/非表示になると100vhの実際のサイズが変わります。window.innerHeightから算出した--vhを使うと、正確なビューポート高さを CSS で参照できます(例:height: calc(var(--vh, 1vh) * 100);)。 -
表示直前に高さ再計算
modal.scrollHeightはモーダル内部の実際の高さです。ビューポートの 90% 以内に収めつつ、コンテンツがそれより小さければそのまま表示します。 -
bodyのoverflow制御
背景スクロールを止めるだけでなく、position: fixed; top: -${window.scrollY}px;という手法もありますが、簡易的にはoverflow: hiddenが最も一般的です。
ハマった点やエラー解決
| ハマりポイント | 原因 | 解決策 |
|---|---|---|
| モーダルが表示された瞬間にスクロールバーがちらつく | body の overflow を切り替えるタイミングが遅れた |
openModal の最初で document.body.style.overflow = 'hidden'; を実行し、表示前に確実に適用 |
iOS で 100vh がアドレスバー分だけ大きくなる |
Safari のビューポート計算バグ | --vh カスタムプロパティで window.innerHeight をベースに算出し、CSS で height: calc(var(--vh) * 100); を使用 |
| モーダル内部の画像がロード後に高さ超過しスクロールできなくなる | 画像のロード完了後に高さが変化したが、再計算が走らなかった | load イベントリスナーで openModal の高さ再計算ロジックを再実行 |
キーボード操作で Esc が効かない |
keydown のリスナーが backdrop に付いていなかった |
document へリスナーを付与し、モーダルが開いているか判定してから閉じる処理を実行 |
完全版サンプル
Html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Fixed Modal の正しい実装例</title> <link rel="stylesheet" href="style.css"> <style> /* ビューポート高さのカスタムプロパティ利用例 */ .modal { max-height: calc(var(--vh, 1vh) * 90); } </style> </head> <body> <button id="openBtn">モーダルを開く</button> <!-- 上記で示した modal markup をここに貼り付け --> <div class="modal-backdrop" id="modalBackdrop"> <!-- ... --> </div> <script src="modal.js"></script> <script> document.getElementById('openBtn').addEventListener('click', openModal); </script> </body> </html>
このサンプルは、画面幅・高さがどんなデバイスでも、モーダルの上部が見切れず、必要に応じて内部スクロールが機能することを保証します。さらに Esc キーやクリックでの閉じる操作、iOS のビューポートバグ対策も網羅しています。
まとめ
本記事では、position:fixed を利用したモーダルウィンドウが画面が狭いと上部が見切れ、スクロールできない問題の原因を「ビューポート高さ超過」と「背景スクロール抑制」に分解し、CSS と JavaScript の組み合わせで安全に対処する方法を解説しました。
- 原因:モーダル全体が
100vh超えてしまい、overflow: hiddenがスクロールを阻害 - 基本解決策:
max-heightとflexレイアウトで内部スクロールを許可 - 拡張解決策:
--vhカスタムプロパティで iOS のビューポートバグに対応し、表示直前に高さ再計算
この手順を踏めば、デバイスやウィンドウサイズに依存しない、ユーザーに優しいモーダル UI を実装できます。次は、ARIA 属性をフル活用したアクセシビリティ強化や、React / Vue コンポーネント化についての記事を執筆予定です。
参考資料
- MDN Web Docs – position
- MDN Web Docs – vh unit
- CSS Tricks – The height: 100vh problem on mobile browsers
- W3C – Accessible Rich Internet Applications (ARIA) 1.2
- iOS Safari の viewport バグまとめ (Qiita)