兩張都是同一份 production build,同一個瀏覽器引擎(WebKit),差別只有下面這幾行:
| 你會看到 | 真正的原因 | 修法 |
|---|---|---|
| 往上拉露出一片淺米色 | 橡皮筋露出的是 canvas,而它只讀 <html> 的背景 |
背景搬到 :root / :root.dark,body 不帶 bg-* |
| 狀態列是不透明的品牌色,跟頁面接不起來 | 沒有 black-translucent;theme-color 有兩條 media 版本,JS 改到錯的那條 |
三個 meta 湊齊,theme-color 只留一條由 JS 改寫 |
| 頂部那條跟著頁面被拉走,底部那條不會 | sticky 在自己的正常位置時就只是普通元素 |
header 改 position: fixed,<main> 自己留白 |
| 內容鑽進瀏海底下,內層 sticky 各差一個 inset | header 高度含安全區,但別的地方還寫死 3.5rem |
--header-h: calc(3.5rem + env(safe-area-inset-top)) |
一週內連續處理四張回報(狀態列顏色、瀏海切掉 header、往上拉露出淺米色、 容器底部被切掉),最後發現它們共享同一個結構:
這些 bug 在一般瀏覽器裡看起來完全正常。 沒進 standalone 時
env(safe-area-inset-*)一律是0, 沒有橡皮筋回彈就看不到 canvas 背景, 桌機沒有會收合的網址列。也就是說,壞掉的只有已經把網站加到主畫面的那群人 —— 而他們最不會回報, 因為他們以為「這網站本來就長這樣」。
以下每一條都附「為什麼難發現」和「怎麼守」。技術棧是 React + Vite + Tailwind, 但坑本身跟框架無關。
想讓頁面延伸到瀏海/動態島底下(原生 App 的樣子),要同時做三件事:
<!-- index.html -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />/* styles.css */
.safe-top { padding-top: env(safe-area-inset-top); }
.safe-bottom { padding-bottom: max(1rem, env(safe-area-inset-bottom)); }三者的關係是連鎖的,而且每一環斷掉的症狀都不一樣、都不報錯:
| 少了什麼 | 症狀 |
|---|---|
viewport-fit=cover |
所有 env() 一律回 0。black-translucent 仍然「設定成功」,內容照樣被瀏海蓋住。 |
black-translucent |
狀態列是不透明的白/黑條,頁面接不上去 —— 看起來像網頁,不像 App。 |
.safe-top |
狀態列透明了,但 header 上半直接被瀏海切掉。 |
max(1rem, env(...)) 不是裝飾。 舊 iPhone / iPad 的 bottom inset 是 0,
只寫 env() 的話底部元素會貼死在螢幕邊緣。
header 有底色(bg-white/95 backdrop-blur)。padding 讓底色一起延伸上去、
蓋住狀態列後方;margin 會在狀態列那塊露出頁面背景,變成一條色帶。
回報是:「往上拉的時候,上面那條會跟著飄下來,底下那條不會。」
原因藏在 sticky 的定義裡:
position: sticky是「在自己的正常位置與top之間切換」。
捲到最頂端時,header 就在它的正常位置 —— 此時它跟一般元素沒有兩樣。 iOS 橡皮筋回彈把整份文件往下平移,它當然跟著走。 sticky 沒有能力浮到自己的正常位置之上。
底部導覽列一直都是 fixed(相對 viewport 定位),所以從來不會飄。差別就在這裡。
- <header className="safe-top sticky top-0 z-30 …">
+ <header className="safe-top fixed top-0 left-0 right-0 z-30 …">
- <main className="flex-1 pb-[var(--bottom-nav-h)]">
+ <main className="flex-1 pt-[var(--header-h)] pb-[var(--bottom-nav-h)]">代價是 header 脫離文件流、空間要自己留。上下兩邊要對稱地補 —— 少一邊,內容 就被那一條蓋住。
這是上面兩條的複利。給 header 加了 .safe-top 之後,它的高度就從 3.5rem
變成 3.5rem + inset —— 而所有「貼在 header 底下」的東西還寫死 3.5rem:
styles.css .substick 的 top: calc(3.5rem + …)
App.tsx 離線橫幅 sticky top-14
Question.tsx 分頁 strip sticky top-14
Chat.tsx h-[calc(100dvh - 3.5rem - var(--bottom-nav-h))]
LectureReader h-[calc(100dvh - 3.5rem)]
Profile × 8 scroll-mt-20
ProfileToc const HEADER_OFFSET = 80
在一般瀏覽器裡 inset 是 0,這些全都是對的。 只有在有瀏海的 iPhone 上加到 主畫面之後,那些內層 sticky 才會各自往上鑽進 header 底下一個 inset 的高度。
收斂成變數,兩端對稱:
:root {
--header-h: calc(3.5rem + env(safe-area-inset-top));
--bottom-nav-h: 0px;
}
@media (max-width: 767px) {
:root { --bottom-nav-h: calc(3.5rem + max(1rem, env(safe-area-inset-bottom))); }
}JS 需要這個值時,render 時去讀計算值,不要抄一份常數:
const px = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue('--header-h'),
);回報:「深色模式下往上拉,露出一片淺米色。」
背景原本掛在 <body class="bg-ink-50"> —— 一個沒有深色版本的靜態 class。
深色背景其實在 React 最外層那個 <div className="dark:bg-ink-900"> 上。
但 iOS Safari 拉過頭時露出的那塊叫 canvas,它的背景色只取自 <html>:
- 掛在
<body>上的:只有在<html>沒有背景時才會被「借用」上去, 而且它沒有深色版本。 - 掛在任何內層
<div>上的:一律看不到。
所以三個主題的回彈區一直都是同一片 #f7f5f2。
:root { color-scheme: light; background-color: #f7f5f2; }
:root.dark { color-scheme: dark; background-color: #0c0a06; }
:root.eink { color-scheme: light; accent-color: #000; caret-color: #000; }- <body class="bg-ink-50 text-ink-800 …">
+ <body class="text-ink-800 …"><input type="time">、checkbox、捲軸、下拉選單的配色不吃 Tailwind 的
dark: —— 它們由瀏覽器依 color-scheme 繪製。沒宣告的話一律以淺色模式畫,
於是深色底上的時間選擇器會拿到近黑色的文字,黑到看不見。
染狀態列/瀏覽器 chrome 的是這個 meta。看起來很自然的寫法是:
<!-- 別這樣 -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1a160f" />兩個問題:
- 主題如果是 class-based 的(使用者可以在系統深色時選淺色),media 版本 在那種組合下會讓狀態列跟頁面顏色相反。 media 問的是系統,不是你的 app。
- 更糟的是靜默失效:切換主題的程式通常是
document.querySelector('meta[name="theme-color"]')—— 只會拿到第一條。 加了 media 版本之後,它從此把值寫進「只在系統為淺色時生效」的那一條, 手動切主題再也染不到狀態列,而且不會有任何錯誤。
正確做法是一條沒有 media 的,由 JS 驅動:
export function applyTheme(t: Theme) {
const root = document.documentElement;
const wantsDark = /* …class-based 判斷… */;
root.classList.toggle('dark', wantsDark);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', wantsDark ? '#1a160f' : '#ffffff');
}值要對齊 header 的底色,不要對齊品牌色。 原本設成 accent(磚紅),深色 模式下瀏覽器那條 bar 是亮橘紅、跟頁面完全接不起來 —— 而「接得起來」正是 整件事的目的。
(manifest.json 裡的 theme_color 是另一件事:那個決定的是啟動畫面和
工作切換器裡的顏色,用品牌色是對的。)
iOS Safari 的 100vh 是「網址列收起來時」的高度。網址列還在的時候,
容器就比視窗高一截,底部被切掉。
- <div className="h-screen …"> /* Tailwind 的 h-screen 就是 100vh */
+ <div className="h-dvh …">dvh(dynamic viewport height)會跟著網址列的收合即時變。全螢幕容器一律用它。
桌機的斷點以上不受影響 —— 沒有會收合的網址列,所以 md:h-screen 那種
寫法不是 bug。但既然 dvh 在桌機等價於 vh,統一換掉比較省事。
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" /> <!-- 一條,不帶 media,JS 改寫 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <!-- display: standalone -->:root { color-scheme: light; background-color: <淺色底>; }
:root.dark { color-scheme: dark; background-color: <深色底>; }
:root {
--header-h: calc(3.5rem + env(safe-area-inset-top));
--bottom-nav-h: calc(3.5rem + max(1rem, env(safe-area-inset-bottom)));
}- header:
fixed+padding-top: env(safe-area-inset-top) - 底部導覽:
fixed+padding-bottom: max(1rem, env(safe-area-inset-bottom)) <main>:padding-top: var(--header-h)/padding-bottom: var(--bottom-nav-h)- 全螢幕容器:
dvh,不是vh - 內層 sticky:
top: var(--header-h),不要寫死
這一整類問題最麻煩的地方是沒有執行期訊號。整理下來需要兩層守門, 而且它們防的不是同一件事、不能互相取代:
Playwright 驗不到安全區 —— Chromium 和 WebKit 兩個引擎都不模擬
env(safe-area-inset-*),跑起來永遠是 0,跟壞掉的情況一模一樣。
所以唯一擋得住的角度是斷言「兩半都在」這件靜態事實:
test('viewport-fit=cover 一定要在 —— 少了它 env() 一律回 0', () => {
assert.match(INDEX_HTML, /name="viewport"[^>]*viewport-fit=cover/);
});
test('header 掛著 .safe-top —— 這是 black-translucent 的另一半', () => {
assert.match(APP_TSX, /<header className="safe-top /);
});
test('theme-color 只能有一條,而且不帶 media', () => {
const tags = INDEX_HTML.match(/<meta name="theme-color"[^>]*>/g) ?? [];
assert.equal(tags.length, 1);
assert.ok(!/media=/.test(tags[0]));
});「不要寫死高度」也只能靠靜態掃描,因為 inset 為 0 時行為完全正確 —— 沒有任何一個瀏覽器能讓那個 bug 顯現。所以直接掃全樹:
test('沒有人再寫死 header 高度 —— 一律吃 var(--header-h)', () => {
// 走訪所有 .ts/.tsx/.css,剝掉註解後找 `top-14` 或 `3.5rem`,
// 除了 --header-h / --bottom-nav-h 兩個變數自己的定義處,一律失敗。
});反過來,:root 的背景色靜態掃描看不到 cascade —— 只要有人在 body 或
#root 補一個不透明背景,回彈區就又壞了,而 :root 那條規則還好端端地在
檔案裡。這種只能真的開瀏覽器量 computed style:
// 三個主題各驗一次
const bg = await page.evaluate(() =>
getComputedStyle(document.documentElement).backgroundColor);- 先確認新測試在修正前會紅。 這類斷言太容易寫成恆真 —— 上面每一條都 實際回退驗證過。
- 斷言「某個東西不見了」時,一定要有一個對照組先證明它出得來。 例如「窄螢幕看不到品牌字」要配一條「寬螢幕看得到」,否則選擇器一腐爛, 測試就退化成空掃的綠燈。
把上面七條放在一起看,共同點很清楚:
| 一般瀏覽器 | 加到主畫面的 iPhone | |
|---|---|---|
env(safe-area-inset-*) |
0 |
實際的瀏海高度 |
| 橡皮筋回彈 | 沒有(桌機) | 露出 canvas |
| 網址列 | 不會收合 | 會,所以 100vh 是錯的 |
| 狀態列 | 不存在 | 由 theme-color 染色 |
開發環境剛好是每一條的「正常」那一欄。 你看不到,CI 看不到, Playwright 看不到 —— 只有使用者看得到,而使用者只會說「怪怪的」。
所以這類東西的守門必須寫在「兩半都在」的層次,而不是「跑起來對不對」的層次。
