行動瀏覽器有兩個視口。**佈局視口(layout viewport)**是 CSS 版面計算的基準,vhvwposition: fixed、初始包含塊都掛在它上面。視覺視口(visual viewport)是使用者此刻真正看到的那塊,會隨捏合縮放與工具列展開收合而變。100vh 之所以在手機上「爆掉」,就是因為它綁的是佈局視口,而佈局視口的高度取自工具列收起時的狀態——工具列展開時,你的 100vh 元素底部有一截被壓在工具列底下。

這不是 bug,是規範選擇:如果 vh 跟著工具列變,任何用 vh 排版的頁面在捲動時都會不斷重排。付出的代價就是滿版元素會超出可視區。

meta viewport:完整寫法與每個欄位的意義

<meta name="viewport"
      content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
欄位對滿版遊戲的意義
width=device-width固定讓佈局視口等於裝置寬度。不寫的話行動瀏覽器會假設 980px 寬再縮小整頁
initial-scale=1固定初始不縮放。與 width=device-width 同時寫是為了繞過舊 iOS 的轉向縮放 bug
viewport-fitauto(預設)/containcover必須 cover 才能畫到瀏海與圓角區域;也只有 cover 才會讓 env(safe-area-inset-*) 變成非零
interactive-widgetresizes-visual(預設)/resizes-contentoverlays-content決定虛擬鍵盤彈出時哪個視口縮小。Chrome 108+、Firefox 132+ 支援,Safari 尚未
user-scalable / maximum-scaleno / 1有無障礙代價,見下

user-scalable=no 的取捨

MDN 的立場明確:關閉縮放會讓低視力使用者無法閱讀內容,違反 WCAG 2.1(要求至少可放大到 200%)。而且 iOS Safari 從 10 起就忽略 user-scalable=no,寫了也擋不住捏合縮放。

滿版遊戲確實需要擋掉雙擊縮放(否則快速連點會觸發放大),但正確工具是 CSS 而不是 meta:

.game-root {
  touch-action: none;        /* 完全接管手勢:不捲動、不縮放、不雙擊放大 */
}
/* 非遊戲區(設定頁、說明頁)保留縮放能力 */
.settings {
  touch-action: manipulation; /* 只拿掉雙擊縮放與 300ms 點擊延遲,保留捏合縮放 */
}

touch-action: manipulation 等同 pan-x pan-y pinch-zoom,是無障礙上可接受的預設;none 只用在真正需要接管全部手勢的畫布本身。

四組視口高度單位

CSS Values 4 定義了三種視口尺寸,各自有完整單位家族(*vh*vw*vmin*vmax、以及邏輯軸的 *vi*vb):

單位定義工具列展開時工具列收起時穩定性
lvh(large)假設所有可收合 UI 都已收起比可視區大剛好固定值
svh(small)假設所有可收合 UI 都已展開剛好比可視區小固定值
dvh(dynamic)隨當下 UI 狀態即時變動剛好剛好會變動
vh(legacy)等同 lvh比可視區大剛好固定值

三者在桌機與沒有動態 UI 的環境完全相同,所以可以無條件使用。支援度上,svhlvhdvh 於 Chrome 108+、Edge 108+、Firefox 101+、Safari 15.4+ 起可用,2025 年 6 月成為 Baseline Widely available。

該用哪一個

dvh 有一個常被忽略的限制:它不保證 60fps 更新。 web.dev 的說明寫得很直接——動態視口的值在 UA 介面展開/收合過程中會被節流,某些瀏覽器甚至會依手勢類型(緩慢捲動 vs 快速滑動)整個 debounce。用 height: 100dvh 當遊戲主容器,在工具列滑出滑入的那 200ms 內會看到畫面高度階梯式跳動,而每次跳動都會觸發你的 resize 重算與 canvas 重建。

所以實務上的分工是:

/* ✅ 遊戲主容器:用 fixed 脫離文件流,高度由 inset 決定,完全不受工具列影響 */
.game-root {
  position: fixed;
  inset: 0;
}
 
/* ✅ 需要「一定看得到」的固定 UI:用 svh(最保守的高度) */
.hud-bottom {
  bottom: max(env(safe-area-inset-bottom), 8px);
}
 
/* ✅ 文件型頁面的 hero 區塊:用 dvh,讓它跟著工具列變高變矮觀感最好 */
.landing-hero {
  min-height: 100dvh;
}
 
/* ❌ 遊戲畫布容器不要用 dvh,抖動會傳導成 canvas 重建 */

position: fixed; inset: 0 之所以最穩,是因為它的包含塊是佈局視口,且瀏覽器對它的處理已針對工具列狀況特殊化——實測上它比任何 vh 變體都貼合可視區,而且不會在捲動中變動。

dvh 與虛擬鍵盤

dvh 不會因為虛擬鍵盤彈出而變小(在預設的 interactive-widget=resizes-visual 下,鍵盤只縮小視覺視口,佈局視口不動)。想讓 dvh 反映鍵盤,必須改成 interactive-widget=resizes-content;Safari 尚未支援,所以 iOS 上仍得靠 visualViewport 手動補償,做法見 鍵盤與 visualViewport

一份可直接抄的基準設定

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport"
        content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
  <!-- 加到主畫面後以 standalone 執行,是 iPhone 上取得「無工具列滿版」的實際手段 -->
  <meta name="mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="theme-color" content="#000000">
</head>

apple-mobile-web-app-status-bar-style: black-translucent 會讓 standalone PWA 的內容延伸到狀態列底下——這是 iPhone 上少數能真正拿到整塊螢幕的方法,代價是你必須用 env(safe-area-inset-top) 自己避開狀態列。

檢查清單

  • viewport-fit=cover 有寫(否則 env(safe-area-inset-*) 恆為 0)
  • 沒有用 user-scalable=no 當作防縮放手段,改用 touch-action
  • 遊戲主容器用 position: fixed; inset: 0,不是 100vh 也不是 100dvh
  • 固定 UI 的定位基準用 svhenv(),不用 lvhvh
  • 有實機在「工具列展開」與「收起」兩種狀態下各測一次

下一篇把這些設定組裝成一個真的不會出捲軸、不會橡皮筋、不會被瀏海切到的容器 → 真正滿版的容器