Skip to content

Instantly share code, notes, and snippets.

@htlin222
Last active August 10, 2026 11:51
Show Gist options
  • Select an option

  • Save htlin222/1da66feeaf63fa704b73a20723490212 to your computer and use it in GitHub Desktop.

Select an option

Save htlin222/1da66feeaf63fa704b73a20723490212 to your computer and use it in GitHub Desktop.
讓 iPhone 上的網站看起來像原生 App —— 七個只在「已加到主畫面」時才會壞的坑(safe-area / theme-color / canvas 背景 / sticky vs fixed / dvh)

讓 iPhone 上的網站看起來像原生 App —— 七個只在「已加到主畫面」時才會壞的坑

修正前後對照:左邊往上拉露出一片淺米色、狀態列是不透明的品牌色、header 跟著頁面被拉走;右邊 header 釘在頂端、露出的底色跟頁面同色

兩張都是同一份 production build,同一個瀏覽器引擎(WebKit),差別只有下面這幾行:

你會看到 真正的原因 修法
往上拉露出一片淺米色 橡皮筋露出的是 canvas,而它只讀 <html> 的背景 背景搬到 :root / :root.dark,body 不帶 bg-*
狀態列是不透明的品牌色,跟頁面接不起來 沒有 black-translucenttheme-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, 但坑本身跟框架無關。


1. 頂端安全區是兩個檔案各一半,只改一邊不會報錯

想讓頁面延伸到瀏海/動態島底下(原生 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() 一律回 0black-translucent 仍然「設定成功」,內容照樣被瀏海蓋住。
black-translucent 狀態列是不透明的白/黑條,頁面接不上去 —— 看起來像網頁,不像 App。
.safe-top 狀態列透明了,但 header 上半直接被瀏海切掉。

max(1rem, env(...)) 不是裝飾。 舊 iPhone / iPad 的 bottom inset 是 0, 只寫 env() 的話底部元素會貼死在螢幕邊緣。

padding 不要用 margin

header 有底色(bg-white/95 backdrop-blur)。padding 讓底色一起延伸上去、 蓋住狀態列後方;margin 會在狀態列那塊露出頁面背景,變成一條色帶。


2. header 要 fixed,不是 sticky —— 橡皮筋回彈會把它帶走

回報是:「往上拉的時候,上面那條會跟著飄下來,底下那條不會。」

原因藏在 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 脫離文件流、空間要自己留。上下兩邊要對稱地補 —— 少一邊,內容 就被那一條蓋住。


3. 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'),
);

4. 橡皮筋回彈露出的是 canvas 的背景,而 canvas 取的是 <html>

回報:「深色模式下往上拉,露出一片淺米色。」

背景原本掛在 <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 …">

順帶一提:color-scheme 是原生控制項唯一的開關

<input type="time">、checkbox、捲軸、下拉選單的配色不吃 Tailwind 的 dark: —— 它們由瀏覽器依 color-scheme 繪製。沒宣告的話一律以淺色模式畫, 於是深色底上的時間選擇器會拿到近黑色的文字,黑到看不見。


5. theme-color 只能有一條,而且不要帶 media

染狀態列/瀏覽器 chrome 的是這個 meta。看起來很自然的寫法是:

<!-- 別這樣 -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)"  content="#1a160f" />

兩個問題:

  1. 主題如果是 class-based 的(使用者可以在系統深色時選淺色),media 版本 在那種組合下會讓狀態列跟頁面顏色相反。 media 問的是系統,不是你的 app。
  2. 更糟的是靜默失效:切換主題的程式通常是 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 是另一件事:那個決定的是啟動畫面和 工作切換器裡的顏色,用品牌色是對的。)


6. 手機上永遠不要用 100vh

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,統一換掉比較省事。


7. 一份最小 checklist

<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),不要寫死

8. 怎麼驗 —— 兩種缺陷要用兩種完全不同的工具

這一整類問題最麻煩的地方是沒有執行期訊號。整理下來需要兩層守門, 而且它們防的不是同一件事、不能互相取代:

靜態掃描:防「兩半只做了一半」

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 兩個變數自己的定義處,一律失敗。
});

真的跑瀏覽器:防「規則還在,但被 cascade 蓋掉」

反過來,:root 的背景色靜態掃描看不到 cascade —— 只要有人在 body#root 補一個不透明背景,回彈區就又壞了,而 :root 那條規則還好端端地在 檔案裡。這種只能真的開瀏覽器量 computed style:

// 三個主題各驗一次
const bg = await page.evaluate(() =>
  getComputedStyle(document.documentElement).backgroundColor);

兩個貫穿的原則

  • 先確認新測試在修正前會紅。 這類斷言太容易寫成恆真 —— 上面每一條都 實際回退驗證過。
  • 斷言「某個東西不見了」時,一定要有一個對照組先證明它出得來。 例如「窄螢幕看不到品牌字」要配一條「寬螢幕看得到」,否則選擇器一腐爛, 測試就退化成空掃的綠燈。

附錄:為什麼這一整類 bug 特別難抓

把上面七條放在一起看,共同點很清楚:

一般瀏覽器 加到主畫面的 iPhone
env(safe-area-inset-*) 0 實際的瀏海高度
橡皮筋回彈 沒有(桌機) 露出 canvas
網址列 不會收合 會,所以 100vh 是錯的
狀態列 不存在 theme-color 染色

開發環境剛好是每一條的「正常」那一欄。 你看不到,CI 看不到, Playwright 看不到 —— 只有使用者看得到,而使用者只會說「怪怪的」。

所以這類東西的守門必須寫在「兩半都在」的層次,而不是「跑起來對不對」的層次。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment