这不是一个简单的“给 safe area 填个背景色”问题。
文章页使用 HueTexture 把封面图扩展成全屏纹理,并叠加视差滚动和桌面端按压反馈。在桌面浏览器里一切正常,但到了 iPhone Safari,几个看似相关、实则分属不同层级的问题同时出现了:
- 页面顶部的正文可以滚进状态栏,看起来像文字穿透了系统 UI;
- Safari 底部 URL 胶囊周围出现纯白或近白色断层;
- 地址栏展开、收起时,背景偶尔跳动或露出色块;
- 滚到文章末尾,Footer 被浮动工具栏挡住;
- 为了修复移动端铺满而恢复视差后,横向封面又可能上下露底。
最终的解决办法不是某一个神奇的 CSS 属性,而是重新划清三条边界:浏览器 UI 与网页的边界、viewport 背景与组件交互层的边界、物理安全区与动态工具栏的边界。
先建立正确的心智模型:Browser Chrome 不是网页#
iOS Safari 顶部的状态栏、底部的 URL 胶囊、Home Indicator,以及它们周围的玻璃和模糊效果,统称为 browser chrome。它们由 Safari 合成,不属于页面 DOM。
网页能够控制的是:
- html、body 和普通 DOM;
- Shadow DOM、伪元素和 fixed 图层;
- viewport 内实际绘制的像素;
- env(safe-area-inset-*) 提供的物理安全区;
- meta theme-color 提供的浏览器颜色建议;
- layout viewport、visual viewport 和滚动内容。
网页不能直接控制的是:
- Safari URL 胶囊的位置、尺寸和透明度;
- 状态栏文字和系统玻璃的最终合成;
- 动态工具栏何时展开或收起;
- 当前工具栏的精确高度;
- 用 z-index 把网页元素画到 Safari UI 上方。
因此,目标不应该是“盖住 Safari 地址栏”,而应该是:
当 Safari 合成半透明系统 UI 时,保证它下面始终存在连续、正确、与封面协调的网页像素;同时保证正文和 Footer 可以避开真正不可用的区域。
这一区分很重要。否则很容易陷入不断调 body 背景、z-index、固定色条高度,却始终无法解释为什么静止时好像修好了,一滚动又变白。
苹果正式提供了什么#
普通 Safari 标签页没有“设置状态栏背景图”或“读取浮动地址栏高度”的专用 API。可依赖的标准能力主要有四组。
viewport-fit=cover:允许网页绘制到屏幕边缘#
文章路由使用 Next.js 的 Viewport 配置:
import type { Viewport } from 'next'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
colorScheme: 'light dark',
themeColor: '#625850',
}
viewport-fit=cover 让页面进入 edge-to-edge 布局。没有它,网页本来就不会延伸到刘海和圆角屏幕边缘。
但它只是在说“允许画过去”,并没有自动保证内容安全,也不会替网页决定该画什么。
env(safe-area-inset-*):描述物理安全区#
典型用法是:
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
这些值用于避开刘海、圆角和 Home Indicator。它们不等于 Safari 浮动 URL 工具栏的完整高度。新版 Safari 的 URL 胶囊可能占据比 safe-area-inset-bottom 更大的视觉空间,而且会随着滚动改变状态。
theme-color:提示,不是绘图 API#
<meta name="theme-color" content="#625850" />
theme-color 会向浏览器提供一个纯色建议,但 Safari 可以根据版本、滚动状态和 UI 形态决定如何使用它。它不能替代网页边缘真实存在的像素。
为了确保首屏和图片解码前不出现纯白,服务端先输出一个稳定的 sRGB fallback;封面加载完成后,再把它替换为从图片取样得到的 rgb(...)。
VisualViewport 和现代 viewport 单位#
移动浏览器中,传统的 100vh 很难表达工具栏展开和收起造成的变化。现代单位提供了三个语义:
- svh:工具栏通常完全展开时的较小 viewport;
- lvh:工具栏通常收起后的最大 viewport;
- dvh:当前动态 viewport。
所以 100lvh - 100dvh 可以作为“当前动态工具栏额外占用空间”的标准化近似。它不是 URL 胶囊高度的官方 API,但比硬编码 80px、128px 更有可解释性。
JavaScript 侧则通过 VisualViewport 的 resize 和 scroll 事件,让背景几何和底部余量在工具栏动画期间及时更新。
为什么前几轮修复没有真正解决问题#
只改 html、body 和 theme-color#
把根背景改成封面主题色是必要的 fallback,但它无法保证 fixed 纹理在 Safari 重建合成层时仍然存在;theme-color 也只是一条建议。
如果真正的纹理层已经被裁掉,Safari 最终看到的仍然只能是根背景。更糟的是,HueTexture 对浅色封面计算出的内部 --background 可能接近白色,于是页面看起来仍像出现了纯白断层。
用透明 fixed 色条“诱导”Safari 取色#
我们曾经在上下边缘放过 fixed 色条,并尝试从 1px 调整到 8px,再设置 opacity: 0,希望 Safari 会扫描它们的 background-color。
这个方案最大的问题不是参数不对,而是没有任何公开规范保证 Safari 会这样工作:
- CSS 中存在颜色声明,不等于屏幕边缘存在这个颜色的可见像素;
- opacity: 0 的元素不会为最终合成结果贡献可见像素;
- Safari 静止、快速滚动和工具栏动画期间可能走不同的合成路径;
- 所谓“必须达到某个像素高度才会被扫描”只是观察推测,不是浏览器契约。
最终我们完全删除了这种 sampler,改用真正可见、真正绘制像素的 safe-area guard。
给 fixed 背景套上滚动 host 的裁切#
这是白色断层的真正根因。
HueTexture 是 Web Component。为了防止组件内部的按压 Canvas 影响组件外部,早期实现根据 host 的 getBoundingClientRect() 计算可见矩形,再把裁切同时应用到 fixed 背景和 press 交互层。
问题在于它们根本不属于同一个坐标系:
- fixed 背景属于 viewport;
- host rect 属于滚动文档;
- Safari 工具栏变化时,window.innerHeight、clientHeight、lvh 和 rect 可能在同一帧短暂不同步。
结果是 host clip 会提前收缩,把本应覆盖 viewport 的 fixed 纹理裁掉。哪怕在背景上下多画 128px,最后仍然会被 clip-path 切掉。
这次排障中最关键的结论是:
背景和交互层可以共享同一张图片,但不应该共享生命周期、裁切边界和坐标系。
最终架构#
最终方案由七个互相配合的部分组成。
1. 文章背景的生命周期提升到整个文章路由#
文章页从站点 Header、正文到站点 Footer,全部包在同一个 ArticleHueTexture 中。对这个页面来说,纹理背景的生命周期就是文章路由的生命周期。
因此 fixed artwork 没有必要再按照 host 当前的滚动矩形裁切:
.surface::before,
.surface::after {
content: '';
position: fixed;
pointer-events: none;
background-image: inherit;
background-repeat: no-repeat;
}
.surface::after {
inset:
calc(-1 * var(--hue-texture-safe-top))
calc(-1 * var(--hue-texture-safe-right))
calc(-1 * var(--hue-texture-safe-bottom))
calc(-1 * var(--hue-texture-safe-left));
background-position:
center
calc(50% + var(--hue-texture-parallax-offset-y));
background-size: var(--hue-texture-background-size, cover);
}
背景永远覆盖 viewport;host 是否滚到页面顶部或底部,不再决定背景是否存在。
2. press 交互层保留独立裁切#
桌面端鼠标移动和点击会驱动 HueTexture 的 WebGL press 效果。这个效果确实只应该存在于组件内部,所以它仍然按照 host 可见区域裁切:
const bounds = element.getBoundingClientRect()
const visibleTop = clamp(bounds.top, 0, viewportHeight)
const visibleBottom = clamp(bounds.bottom, 0, viewportHeight)
element.style.setProperty(
'--hue-texture-press-clip-top',
pressClipTop + 'px',
)
element.style.setProperty(
'--hue-texture-press-clip-bottom',
pressClipBottom + 'px',
)
移动端没有 fine pointer,本来就不启用 press;Footer 进入 viewport 时,桌面端 press 也会暂停,避免在文章底部出现视觉抖动。视差仍继续运行。
换句话说:
- background:文章 route 级,覆盖 viewport,不做 host clip;
- press:组件交互级,保留自己的 clip;
- parallax:背景几何级,独立于 press 开关。
3. 使用真实的 Light DOM safe-area guard#
文章组件内部增加顶部和底部两个真实节点:
<hue-texture>
<div
aria-hidden
className="article-safe-area-guard"
data-edge="top"
/>
<div
aria-hidden
className="article-safe-area-guard"
data-edge="bottom"
/>
<div className="article-content-layer">{children}</div>
</hue-texture>
它们有几个刻意的设计:
- 位于 Light DOM,而不是只藏在 Web Component 的 Shadow DOM 里;
- 只在 hover: none 且 pointer: coarse 的触摸设备上显示;
- 不使用 opacity: 0;
- pointer-events: none,不拦截页面交互;
- 使用封面取样后的 --article-chrome;
- 顶部阻止正文进入物理状态栏;
- 底部为 Safari 半透明 URL UI 提供真实的非白色承托像素。
.article-safe-area-guard {
position: fixed;
inset-inline: 0;
z-index: 59;
display: none;
pointer-events: none;
background: var(
--article-chrome,
var(--article-canvas, var(--article-canvas-fallback))
);
}
.article-safe-area-guard[data-edge='top'] {
top: calc(-1 * env(safe-area-inset-top, 0px));
height: env(safe-area-inset-top, 0px);
}
.article-safe-area-guard[data-edge='bottom'] {
bottom: calc(
-1 * env(safe-area-inset-bottom, 0px) -
var(--article-browser-chrome-gap)
);
height: calc(
env(safe-area-inset-bottom, 0px) +
var(--article-browser-chrome-gap)
);
}
@media (hover: none) and (pointer: coarse) {
.article-safe-area-guard {
display: block;
}
}
上下 guard 还各自带一小段透明渐变,避免在封面和纯色承托层之间形成新的硬边。
4. 从封面边缘取样明确的 sRGB#
HueTexture 可以生成一套动态调色板,但 Safari 的 theme-color 最稳妥的输入仍然是具体的 sRGB,例如 #625850 或 rgb(44, 42, 44),而不是依赖某个组件内部 CSS 变量被浏览器 UI 间接解析。
图片加载完成后,我们把封面缩到 24×24,并只读取顶部和底部条带:
const sample = 24
const canvas = document.createElement('canvas')
canvas.width = sample
canvas.height = sample
const context = canvas.getContext('2d', {
willReadFrequently: true,
})
context?.drawImage(sourceImage, 0, 0, sample, sample)
const strip = Math.max(1, Math.round(sample / 4))
const topBand = context?.getImageData(0, 0, sample, strip).data
const bottomBand = context?.getImageData(
0,
sample - strip,
sample,
strip,
).data
两个条带求平均后发布三个值:
- --article-canvas:根页面画布的真实颜色;
- --article-chrome:限制过高亮度后的 Safari UI 承托色;
- meta theme-color:同步写入同一个具体 rgb(...)。
为什么 canvas 和 chrome 分开?封面边缘可能接近纯白。页面画布应该尽量忠实于图片,但在半透明浏览器玻璃下,过亮的颜色很容易重新表现为刺眼白带。因此 --article-chrome 会做有限的亮度压缩,而不是任意换色。
如果图片来自跨域地址且污染 Canvas,getImageData() 会抛错。这时保留服务端 theme-color 和 CSS 静态 fallback。项目中的远程封面会优先通过同源图片代理,减少这类失败。
退出文章路由时,还必须清除根 CSS 变量并恢复之前的 meta theme-color,否则一篇暖色封面可能污染博客列表页或下一篇文章。
5. 同时用 lvh/dvh 和 VisualViewport 跟踪动态工具栏#
CSS 先提供无需 JavaScript 的近似:
@supports (height: 100lvh) and (height: 100dvh) {
.article-hue-shell {
--article-browser-chrome-gap:
max(0px, calc(100lvh - 100dvh));
}
}
JavaScript 再用一个隐藏探针测量 large viewport:
const largeViewportProbe = document.createElement('div')
largeViewportProbe.style.cssText =
'position:fixed;width:0;height:100vh;height:100lvh;' +
'visibility:hidden;pointer-events:none;'
const browserChromeGap = Math.max(
0,
largeViewportProbe.offsetHeight - window.innerHeight,
)
并监听:
window.visualViewport?.addEventListener(
'resize',
scheduleSceneUpdate,
)
window.visualViewport?.addEventListener(
'scroll',
scheduleSceneUpdate,
)
所有高频变化都通过 requestAnimationFrame 合并。这里只更新 CSS 变量和几何,不重建 Markdown 正文,避免工具栏动画带来 React 子树反复渲染。
6. 移动端智能裁切与视差必须一起计算#
横向封面放到竖屏手机上,有一个无法回避的几何事实:
“整张图完全可见”“背景没有上下色块”“任何设备都最多只放大 60%”这三个条件不能同时成立。
如果坚持 contain,竖屏一定会露出上下背景;如果坚持 cover,就必须裁掉横向两侧,并且某些宽高比下会超过相对 contain 的 1.6 倍。
最终采用分设备策略:
- 桌面宽屏:以 contain 为基准,最大放大 1.6 倍;
- 窄屏设备:使用 full-bleed cover,优先消灭上下色块;
- 移动端额外预留 32px 视差 overscan;
- 桌面端最大视差 64px;
- 图片几何以 large viewport 加 safe-area bleed 计算,而不是随当前 innerHeight 反复缩放;
- 可用视差行程会扣除安全区超绘制,确保滚动时图片边缘不会重新进入屏幕。
核心计算可以简化为:
const containScale = Math.min(
viewportWidth / naturalWidth,
viewportHeight / naturalHeight,
)
const coverScale = Math.max(
paintWidth / naturalWidth,
paintHeight / naturalHeight,
)
const desktopScale = Math.min(
containScale * 1.6,
coverScale,
)
const mobileScale = Math.max(
paintWidth / naturalWidth,
(paintHeight + 32 * 2) / naturalHeight,
)
这不是简单地“在父级加 overflow: hidden”。父级只负责页面范围;真正知道图片自然尺寸、large viewport、安全区和视差余量的是背景组件,所以智能裁切必须在 HueTexture 的几何适配层完成。
7. 给 Footer 留出真正可滚动的空间#
网页不能移动 Safari URL 胶囊,但可以让文档末尾拥有足够的滚动余量,使 Footer 最终滚到胶囊上方:
@media (hover: none) and (pointer: coarse) {
.article-texture-footer {
padding-bottom: calc(
2rem +
env(safe-area-inset-bottom, 0px) +
max(4rem, var(--article-browser-chrome-gap))
);
}
}
这里同时考虑:
- 页面原本的 2rem 留白;
- Home Indicator 的物理安全区;
- 当前动态工具栏 gap;
- 至少 4rem 的可滚动余量。
另外,safe-area 样式必须使用精确选择器。像 .article-hue-shell header 或 .article-hue-shell footer 这样的宽泛写法,会同时命中文章 Hero 和正文内部的 Thanks / Share Footer,造成内容卡片内部多出一大块空白。
当前只匹配站点 Header 的 data-article-site-header,以及站点 Footer 的 article-texture-footer。
根画布为什么仍然需要保留#
既然已经有 fixed artwork 和真实 guard,为什么还要设置 html/body 背景?
因为移动 Safari 在以下时刻可能短暂重建合成层:
- 图片尚未解码;
- 地址栏正在展开或收起;
- 屏幕方向切换;
- 页面发生 rubber-band overscroll;
- Canvas 取样因 CORS 失败;
- fixed 图层重新分配 GPU 合成表面。
根画布是最后一道不会透明到底的安全网:
html:has(.article-hue-shell),
html:has(.article-hue-shell) body {
background-color:
var(--article-canvas, var(--article-canvas-fallback));
}
最终的稳定性来自多层协作,而不是某一层包办所有问题:
- fixed artwork 提供连续图像;
- visible guard 提供 Safari UI 下方的真实像素;
- html/body canvas 处理瞬态和 overscroll;
- theme-color 提供标准浏览器提示;
- 静态 fallback 负责首屏和失败路径。
做不到什么#
这套方案不会、也不能做到:
- 隐藏普通 Safari 标签页的 URL 胶囊;
- 把 URL 胶囊移动到指定位置;
- 给 Safari 状态栏设置一张背景图;
- 获取一个精确、跨版本稳定的工具栏高度;
- 保证不同 Safari 版本使用完全相同的玻璃、模糊和取色算法。
apple-mobile-web-app-status-bar-style 主要服务于添加到主屏幕后以 standalone 模式运行的 Web App,不是普通 Safari 标签页的通用修复。
我们能保证的是网页这一侧:无论工具栏处于什么状态,它下方尽可能始终有正确像素,内容也有足够空间避开它。
验证不能只靠桌面响应式模式#
桌面 DevTools 能模拟宽度,却通常不能完整模拟 iOS Safari 的状态栏、浮动地址栏、Home Indicator、动态工具栏动画和 rubber-band overscroll。
这类修复至少需要覆盖下面的真机状态。
首屏#
- 服务端 HTML 中存在静态 theme-color;
- 图片尚未解码时不是纯白;
- 图片加载后 theme-color 变成具体 rgb(...);
- 浅色、深色和接近白色的封面都有合理 fallback;
- 图片加载失败或 Canvas 被 CORS 污染时不透明到底。
滚动#
- scrollY = 0;
- 手指拖动但尚未松开;
- 地址栏正在收缩;
- 地址栏完全收缩;
- 快速惯性滚动;
- 返回页面顶部;
- 顶部和底部 rubber-band overscroll。
页面末尾#
- Footer 第一次进入 viewport;
- Footer 被 URL 胶囊覆盖;
- 滚动到最大 scrollY;
- Footer 的关键内容可以完整滚到胶囊上方;
- Footer hover 不再触发 press 图层抖动。
设备变化#
- 竖屏和横屏;
- 刘海位于左侧或右侧;
- 旋转期间 fixed artwork 不重新露底;
- prefers-reduced-motion: reduce 时关闭视差和 press;
- 从文章路由退出后,theme-color 和根 CSS 变量恢复。
最后总结#
这次修复最有价值的并不是某一段 CSS,而是几个可以复用的工程判断:
- 浏览器 UI 不是 DOM。 先判断问题发生在网页布局、网页合成还是 browser chrome。
- theme-color 是提示,不是画布。 它应该和真实页面像素一起使用。
- 不要把实验现象伪装成浏览器 API。 透明 sampler 和“8px 扫描阈值”没有公开契约,不适合作为生产架构。
- viewport 背景不能被滚动 host 的 rect 裁切。 背景与 press 交互层必须分离生命周期。
- safe area 与动态工具栏是两套几何。 前者使用 env(safe-area-inset-*),后者使用 lvh/dvh、VisualViewport 和文档末尾余量。
- 移动端 cover 与视差要共同计算。 只恢复视差、不预留 overscan,图片边缘迟早会被拉回屏幕。
- 最稳定的兼容方案是“真实像素 + 标准提示 + 明确回退”。
问题看起来发生在 Safari 最边缘,真正的根因却藏在组件内部错误共享的裁切坐标系里。把边界重新划清之后,顶部状态栏、底部 URL 工具栏、视差背景和 Footer 可用性才终于变成一套能够解释、能够验证、也能够长期维护的实现。