スマホで height: 100vh を指定したのに、アドレスバーやタブバーの分だけ要素がはみ出てスクロールが発生する。ヒーローセクションやモーダル背景をピッタリ画面いっぱいにしたいのに思い通りにならない。これはモバイルブラウザの仕様によるもので、多くの開発者がつまずく問題です。
この記事では なぜ100vhではうまくいかないのか を仕組みから説明し、CSSの新しい単位 dvh/svh/lvh とJavaScriptによるカスタムプロパティ回避策の両面から解決策を解説します。
100vhでうまくいかない原因
PCブラウザでは 1vh = ビューポートの高さの1% と直感的に一致します。しかしiOS SafariやAndroid Chromeではブラウザ自身のUIバー(アドレスバー・タブバー・ナビゲーションバー)が画面の一部を占有しており、それが vh の計算方法に影響します。
具体的には iOSのSafariは 1vh をUIバーが非表示の状態(最大ビューポート)を基準に計算します。ページを開いたばかりのUIバーが表示されている状態では、100vh の高さが実際の画面より大きくなり、縦スクロールが発生します。
| 状況 | iOSのvhの基準 | 結果 |
|---|---|---|
| UIバー表示中(ページ読み込み直後) | UIバーがない状態の高さ | 100vhがはみ出る |
| スクロールしてUIバーが隠れたとき | ほぼ一致する | 見た目上は問題なし |
| Chrome(Android) | 現在のビューポート高さ | dvhと同様に動作 |
ページ読み込み時にUIバーの表示・非表示が変わると
vh の値が変動し、レイアウトが再計算されてガタつきます。Apple はこのガタつきを防ぐために vh を最大ビューポート(UIバーなし)で固定しました。これが意図的な設計であり、「バグ」ではありません。解決策①:CSS新単位 dvh / svh / lvh(推奨)
この問題を解消するためにCSS仕様に追加された3つのビューポート単位です。2024年現在、主要ブラウザすべてで利用できます。
| 単位 | 名称 | 基準 | UIバー表示中 | UIバー非表示中 |
|---|---|---|---|---|
dvh |
Dynamic Viewport Height | 現在の実際のビューポート | UIバー分だけ小さい | 大きくなる |
svh |
Small Viewport Height | UIバーが最大表示のとき(最小ビューポート) | ピッタリ合う | あまる |
lvh |
Large Viewport Height | UIバーが非表示のとき(最大ビューポート) | はみ出る | ピッタリ合う |
vh |
(従来) | iOSは lvh と同等 |
はみ出る | ピッタリ合う |
.hero {
/* 古いブラウザ向けフォールバック */
height: 100vh;
/* dvh に対応していれば上書き */
height: 100dvh;
}
- dvh:「常に今見えている画面ぴったり」にしたいとき。スクロールするとレイアウトが再計算されることに注意(アニメーションが発生する場合あり)
- svh:「UIバーが出ているときも欠けないようにしたい」ヒーローセクションや全画面背景に最適。UIバー非表示時は少し短くなる
- lvh:従来の
vhと同等。iOSでははみ出る問題は残る
ヒーローセクション・モーダル背景には 100svh か min-height: 100svh が最もトラブルが少なくおすすめです。
/* ヒーローセクション:常に「欠けない」画面いっぱい */
.hero {
min-height: 100vh; /* フォールバック */
min-height: 100svh; /* UIバーあり状態でも欠けない */
display: flex;
align-items: center;
justify-content: center;
}
/* 全画面モーダルオーバーレイ */
.modal-overlay {
position: fixed;
inset: 0; /* top:0 right:0 bottom:0 left:0 の一括指定 */
width: 100%;
height: 100dvh; /* 現在の表示領域ぴったり */
background: rgba(0, 0, 0, 0.5);
}
解決策②:JavaScript で –vh カスタムプロパティを設定する
dvh/svh に対応していない古いブラウザへも対応が必要な場合、JavaScriptで window.innerHeight を取得して CSS カスタムプロパティに渡す方法が広く使われています。
/**
* window.innerHeight を CSS カスタムプロパティ --vh に設定する
* iOS Safari で 100vh が正しく計算されない問題の回避策
*/
function setViewportHeight() {
// 1vh 相当のピクセル値を算出
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
// 初回実行
setViewportHeight();
// リサイズ時・向き変更時に再計算
window.addEventListener('resize', setViewportHeight);
window.addEventListener('orientationchange', () => {
// orientationchange 直後は innerHeight がまだ更新されていない場合がある
setTimeout(setViewportHeight, 100);
});
.full-screen {
/* フォールバック:vh が正しく動作する環境用 */
height: 100vh;
/* --vh が設定されていればこちらを使う */
height: calc(var(--vh, 1vh) * 100);
}
/* min-height 版 */
.hero {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100);
}
var(--vh, 1vh) のフォールバック値について:CSS変数の第2引数はフォールバック値です。JavaScriptが実行される前(SSRや初期描画の一瞬)に
--vh がまだ設定されていない場合、1vh が使われます。これにより JavaScript 実行前でも表示が崩れません。リサイズ・向き変更への対応
スマホの向き変更やソフトキーボード表示時にも --vh が変化します。ただしリサイズイベントを毎回発火させると負荷が高いため、デバウンス処理を入れるのが実務的です。
function setViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
// デバウンス:連続イベントをまとめて1回だけ実行
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// 初回
setViewportHeight();
// resize はデバウンス付き(150ms 後に実行)
window.addEventListener('resize', debounce(setViewportHeight, 150));
// orientationchange は 300ms 待つ(値が安定してから取得)
window.addEventListener('orientationchange', () => {
setTimeout(setViewportHeight, 300);
});
Androidではソフトキーボードが表示されると
window.innerHeight が小さくなり resize が発火します。これにより入力フォームがある全画面UIでレイアウトが崩れる場合があります。visualViewport.height を使うか、入力フォームには --vh を使わないようにするのが実用的な対策です。補足:-webkit-fill-available(非推奨)
古いiOS Safariで使われていたベンダープレフィックス付きの方法です。現在は dvh/svh で代替できるため新規実装では使う必要はありませんが、既存コードで見かけることがあるため紹介します。
.full-screen {
height: 100vh; /* 標準フォールバック */
height: -webkit-fill-available; /* iOS Safari 向け(非推奨) */
height: 100dvh; /* 現行の推奨 */
}
- 非標準のベンダープレフィックスであり、仕様上の保証がない
- Chromeなど他のブラウザで期待通りに動作しないことがある
- iOS 15.4以降は
svh/dvhで同等の効果が得られる
ブラウザサポートと実装戦略
| 手法 | iOS Safari | Chrome(Android) | Chrome(PC) | Firefox | 必要なJS |
|---|---|---|---|---|---|
100vh(従来) |
×(はみ出る) | ○ | ○ | ○ | 不要 |
100dvh |
iOS 15.4+ | Chrome 108+ | ○ | Firefox 101+ | 不要 |
100svh |
iOS 15.4+ | Chrome 108+ | ○ | Firefox 101+ | 不要 |
--vh カスタムプロパティ |
○(全バージョン) | ○ | ○ | ○ | 必要 |
.full-screen {
/* 1. 最古のブラウザ向けフォールバック */
height: 100vh;
/* 2. JS が実行されたら --vh カスタムプロパティを使う(iOS 15.4 未満も対応) */
height: calc(var(--vh, 1vh) * 100);
/* 3. dvh 対応ブラウザではこちらが優先(@supports で上書き) */
}
@supports (height: 100dvh) {
.full-screen {
height: 100dvh;
}
}
- まず
100svh(ヒーロー・背景)か100dvh(モーダル・オーバーレイ)を使う 100vhをフォールバックとして先に書く- iOS 15.4 未満のサポートが必要な場合のみ
--vhJSカスタムプロパティを追加する
Google Analytics などで訪問者のOSバージョンを確認し、iOS 15未満のユーザーがほぼいなければ CSS だけで解決できます。
完成形:フルスクリーンUIコンポーネント
ヒーローセクションとモーダルオーバーレイの2パターンをまとめた実用コードです。
<!-- ヒーローセクション -->
<section class="hero">
<h1>キャッチコピー</h1>
<p>サブテキスト</p>
</section>
<!-- 全画面モーダル -->
<div class="modal-overlay" id="modal" hidden>
<div class="modal-content">
<button class="modal-close">閉じる</button>
<p>モーダル内容</p>
</div>
</div>
/* ヒーローセクション:svh で欠けを防ぐ */
.hero {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
@supports (min-height: 100svh) {
.hero { min-height: 100svh; }
}
/* モーダルオーバーレイ:dvh で現在の画面ぴったり */
.modal-overlay {
position: fixed;
inset: 0;
width: 100%;
height: 100vh;
height: calc(var(--vh, 1vh) * 100);
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
@supports (height: 100dvh) {
.modal-overlay { height: 100dvh; }
}
.modal-content {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 90%;
max-height: 80vh;
overflow-y: auto;
}
// --vh カスタムプロパティを設定(デバウンス付き)
function setVh() {
document.documentElement.style.setProperty(
'--vh', `${window.innerHeight * 0.01}px`
);
}
setVh();
window.addEventListener('resize', debounce(setVh, 150));
window.addEventListener('orientationchange', () => setTimeout(setVh, 300));
function debounce(fn, ms) {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
// モーダル制御
const modal = document.getElementById('modal');
document.querySelector('.modal-close').addEventListener('click', () => {
modal.hidden = true;
});
よくある質問(FAQ)
svh、常に画面全体を覆うオーバーレイは dvh が適しています。100vh と実際の画面が一致するため問題が起きません。CSS の 100svh か、JavaScript の --vh カスタムプロパティを使うと修正できます。window.innerHeight が変化し resize が発火するため、--vh が更新されてレイアウトが変わります。visualViewport.height を使うか、入力フォームを含むエリアには固定高さを使わずに flex: 1 や overflow-y: auto で伸縮させる設計が安定します。dvh/svh は iOS 15.4 以降で対応しているため、訪問者のiOSバージョン分布を確認してから対応方針を決めましょう。--vh は設定されません。CSSの var(--vh, 1vh) のフォールバック値 1vh が使われます。useEffect(React)や onMounted(Vue)の中で setVh() を呼ぶことで、クライアントサイドでの初期化後に正しい値が設定されます。まとめ
| 対応範囲 | 推奨する手法 |
|---|---|
| iOS 15.4以降のみでOK | CSS 100svh(ヒーロー)/ 100dvh(オーバーレイ) |
| iOS 15未満も含めて対応 | JS --vh カスタムプロパティ + CSS calc(var(--vh,1vh)*100) |
| 将来性・メンテナンス性 | CSS新単位を優先し、必要なときだけJSで補う |
| 新規実装の基本方針 | 100svh を書き、フォールバックに 100vh を置く |
要素の高さをJavaScriptで取得・設定する方法は要素の高さを揃える方法(ResizeObserver対応)で、要素の幅・高さの取得プロパティの違いはoffsetWidth・clientWidth・getBoundingClientRectの違いで詳しく解説しています。
