一個滿版容器要同時滿足四件事:填滿整塊螢幕(含瀏海與圓角區)、關鍵 UI 不被硬體遮擋、不產生捲軸、不能被使用者拖動。這四件事各有各的機制,缺一項就會在某支手機上壞掉,而且通常是你手上沒有的那支。
骨架:position: fixed 根容器
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* 文件本身沒有可捲動內容 → 沒有捲軸、沒有橡皮筋 */
overscroll-behavior: none; /* 保險:擋掉下拉刷新與捲動串連 */
background: #000; /* letterbox 的黑邊顏色由這裡決定 */
}
.game-root {
position: fixed; /* 包含塊 = 佈局視口,不受工具列展開收合影響 */
inset: 0; /* top/right/bottom/left 全 0 */
overflow: hidden;
touch-action: none; /* 接管所有手勢 */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none; /* 長按不跳「拷貝/分享」選單 */
-webkit-tap-highlight-color: transparent; /* 點擊不閃灰色方塊 */
}position: fixed; inset: 0 比任何 height: 100vh/100dvh 都可靠:vh 家族會在工具列狀態變化時改變值並觸發重排,而固定定位的包含塊是佈局視口本身,瀏覽器已針對「內容應該貼合可視區」做過特殊處理。html/body { overflow: hidden } 則是根本性地移除捲動來源——沒有可捲動的內容,就沒有可以回彈的東西,iOS 的橡皮筋自然不會出現。
overscroll-behavior: none 是第二道防線,用於你的遊戲裡真的有可捲動子區域(排行榜、道具欄)的情況:
.inventory {
overflow-y: auto;
overscroll-behavior-y: contain; /* 捲到底不會把外層一起帶動 */
-webkit-overflow-scrolling: touch;
}contain 保留區塊內自己的回彈感但阻斷捲動串連;none 連回彈都拿掉。要擋掉 Chrome Android 的下拉刷新,把 overscroll-behavior: none 設在 html 上(設在 body 上在部分瀏覽器不生效,因為根捲動容器的傳播規則)。
安全區:viewport-fit=cover 與 env()
沒有 viewport-fit=cover 時,Safari 會自動把內容縮排到安全區內——你會在瀏海旁邊看到兩條背景色的邊。加上 cover 之後內容延伸到螢幕邊緣,同時四個 env(safe-area-inset-*) 才會回傳非零值。
規則很簡單:背景延伸到邊緣,前景縮排到安全區內。
/* 背景/畫布:滿到邊緣,不做任何 inset */
.stage { position: absolute; inset: 0; }
/* 前景 UI:一律用 max() 保證最小內距,同時避開瀏海 */
.hud {
position: absolute;
inset: 0;
padding-top: max(12px, env(safe-area-inset-top));
padding-right: max(12px, env(safe-area-inset-right));
padding-bottom: max(12px, env(safe-area-inset-bottom));
padding-left: max(12px, env(safe-area-inset-left));
pointer-events: none; /* 讓點擊穿透到畫布 */
}
.hud > * { pointer-events: auto; }max(12px, env(...)) 是 WebKit 官方部落格建議的寫法:直向時 safe-area-inset-left 是 0,直接用 env() 會讓左右完全貼邊;用 max() 就能保證至少有 12px 的視覺內距,同時在橫向被瀏海侵入時自動加大。
幾個容易踩的點:
env()只在viewport-fit=cover下非零,桌機與無瀏海裝置永遠是 0,所以不需要為它們寫例外。- 底部的
safe-area-inset-bottom在有 home indicator 的機型約 34px,橫向時左右各約 44–48px。這些數字不要寫死,一律用env()。 env()可以參與calc():height: calc(100% - env(safe-area-inset-bottom))合法。safe-area-max-inset-*是對應的靜態最大值(所有可收合 UI 收起時的值),適合用來預留固定空間、避免版面在 inset 變化時跳動。
「安全框」與 safe-area 的差別
兩個容易混淆的概念:
env(safe-area-inset-*)是硬體造成的不可用區域(瀏海、圓角、home indicator)。- 安全框(safe frame) 是你自己定義的、保證在所有取景策略下都可見的遊戲內矩形(見 畫布縮放策略)。
實作上要把兩者疊起來:HUD 的定位基準 = 安全框 ∩ 安全區。做法是把 env() 值寫進 CSS 變數,讓 JS 與 CSS 共用同一組數字:
:root {
--sai-top: env(safe-area-inset-top, 0px);
--sai-right: env(safe-area-inset-right, 0px);
--sai-bottom: env(safe-area-inset-bottom, 0px);
--sai-left: env(safe-area-inset-left, 0px);
}// JS 讀回同一組值(例如要在 canvas 內畫 HUD 時)
const cs = getComputedStyle(document.documentElement)
const safeTop = parseFloat(cs.getPropertyValue('--sai-top')) || 0注意 getComputedStyle 讀自訂屬性時,未支援 env() 的環境會回傳空字串,parseFloat 得到 NaN,所以 || 0 是必要的。
純 CSS 的 letterbox 舞台
如果你的取景策略是 letterbox(見下一篇),連 JS 都不需要:
.game-root {
position: fixed; inset: 0;
display: grid;
place-items: center; /* 舞台在剩餘空間中置中 */
background: #000;
}
.stage {
aspect-ratio: 16 / 9; /* 鎖定設計比例 */
max-width: 100%;
max-height: 100%;
width: 100%; /* 先撐滿寬,再被 max-height 壓回去 */
}aspect-ratio + max-width/max-height + 置中容器的組合,效果等同 scale = min(cssW/W, cssH/H),由排版引擎代勞、零 JS、resize 時零成本。這是最該優先考慮的方案——只有當你需要 cover 或 expand 取景、或需要精確控制 canvas backing store 時,才需要進到 JS 版本。
<canvas> 放進 .stage 時記得補:
.stage canvas {
display: block; /* 消除 inline 元素的行底空隙(會造成幾 px 的捲動) */
width: 100%;
height: 100%;
}display: block 這行很常被漏掉。<canvas> 預設是 inline,行盒的 baseline 會在下方留下約 4–5px 的空隙,在 overflow: hidden 的容器裡看不出來,但在別處就會多出捲軸。
檢查清單
-
html, body { overflow: hidden }+ 根容器position: fixed; inset: 0 -
touch-action: none只加在畫布層,設定頁保留manipulation - 所有貼邊 UI 用
max(基準值, env(safe-area-inset-*)) -
<canvas>有display: block - 實測:iPhone 橫向(瀏海在左或右都要試)、有 home indicator 的直向、Android 有導覽手勢列的機型
下一篇是本專欄的核心:畫面該怎麼縮放才對 → 畫布縮放策略