在 iPhone 上看这个博客,有两处不太对:
- 列表页顶部状态栏那一条是另一种颜色,和下面的纸色背景接不上;
- 文章页更明显,顶部、底部各有一块实心色条,正文滚动时被它挡住,透不到屏幕边缘;
- 色条去掉之后,正文能透过去了,背景图却还是停在两条栏之间,下面那块色带的高度还会跟着地址栏伸缩变化。
理想的样子是:安全区看起来就是页面的一部分,内容滚动时一直延伸到屏幕边缘。
Safari 怎么画安全区#
iOS 26 的 Safari 把状态栏和底部地址栏做成了半透明的玻璃,网页可以画到它们下面。但分两种情况:
- 页面停在最顶部(scrollY = 0)时,状态栏那一条不是真实像素,而是一块纯色,通常取
html/body的背景色。theme-color和伪元素都不参与。 - 页面滚动之后,Safari 把真实的页面像素合成到玻璃下面,但只限普通的滚动内容。fixed 元素被排在两条栏之间,到不了栏下面。
- 页面边缘有 fixed / sticky 元素时,Safari 会在两条栏上各盖一块纯色,把下面的一切都挡住,连普通内容也透不过去。
还有一点和以前不同:竖屏下即使声明了 viewport-fit=cover,env(safe-area-inset-top) 和 env(safe-area-inset-bottom) 也都是 0,靠它们往外延伸的写法都不起作用。
所以要做的有三件事:静止时让那块纯色和页面接得上;栏下面要露出来的东西只能是普通滚动内容;页面边缘别放 fixed / sticky 元素。
列表页#
列表页是纯色纸面(浅色 #E0DFD9,深色 #1A1C1A),但 html / body 还是默认的白色和深灰,Safari 取到的正是它们。
tsx
// app/(main)/blog/layout.tsx
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
}
css
html:has([data-page-surface='paper']),
html:has([data-page-surface='paper']) body {
background-color: #e0dfd9;
}
html.dark:has([data-page-surface='paper']),
html.dark:has([data-page-surface='paper']) body {
background-color: #1a1c1a;
}
[data-site-shell] {
padding-top: env(safe-area-inset-top, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
纸面页面的根节点带一个 data-page-surface="paper",:has() 让 html 跟着页面类型换色,不需要 JS 同步。外层容器用 env() 把页头推到状态栏下面。静止时纯色就是纸色,滚动时真实内容从玻璃下透出来。
文章页#
文章页的背景是由封面图生成的全屏纹理(HueTexture),没有一个现成的颜色可填,所以多了一层。
挡住内容的是自己加的色条。 之前为了不让状态栏露白,在顶部、底部各放了一个 position: fixed 的实心色块。它确实决定了静止时的颜色(Safari 会取它的背景色),但它画的是真实像素,滚动后也一直盖在安全区上,内容永远透不过去。删掉即可。
静止时取什么颜色。 色块删掉后,Safari 退回去取 html 的背景色。紧挨状态栏的是页头,一层 60% 不透明的毛玻璃(bg-card/60 + backdrop-blur)叠在纹理上。于是:
- 页头从
margin-top改成padding-top: env(safe-area-inset-top)。竖屏下这个值是 0,横屏或在其它浏览器里,玻璃会一直伸到状态栏下面; html的背景色去模仿这层玻璃:
css
html:has(.article-hue-shell) {
background-color: color-mix(
in oklab,
var(--article-header-glass) 60%,
var(--article-canvas-top)
);
}
两个变量由 JS 提供:
--article-header-glass:HueTexture 根据封面算出的--card,写在组件自己身上,切换主题时会变。用一个MutationObserver把它同步到html。--article-canvas-top:静止时页头背后那段纹理的平均色。它不是原图最上面那几行:纹理按 cover 居中裁切,视差在页面顶部时还整体下移了一段。所以按同样的几何,把「屏幕顶部到页头底部」这段区域映射回原图,再取平均。
本地渲染对比(文章《函数式编程》):
| 安全区纯色 | 页头实际渲染 | |
|---|---|---|
| 浅色 | rgb(173, 170, 166) | rgb(180, 176, 170) |
| 深色 | rgb(30, 27, 25) | rgb(31, 29, 28) |
肉眼基本看不出接缝。
背景图为什么停在半路。 色条删掉后,正文已经能滚进状态栏和地址栏下面,背景图却没有跟过去:图片上下各留一块色带,底部那块还随地址栏收起、展开改变高度。
背景纹理原本是一个 position: fixed 的图层,用负的 env(safe-area-inset-*) 往外撑。在 iOS 26 之后的 Safari 上,fixed 元素被排在两条栏之间,而这两个 env() 又都是 0,图层的大小正好就是栏之间那一块。色带的高度会变,是因为这块区域本身跟着地址栏变化。
先排除几种做法。 我在 iPhone 上做了一个对照页,用几种方式各画一条竖色带,看谁能伸到栏下面:
| 做法 | 结果 |
|---|---|
| fixed,负偏移 | 停在两条栏之间 |
| fixed,背景或 opacity 设为 0.99 | 同上 |
sticky,负的 top | 同上,而且两条栏都变成纯色,盖住了所有内容 |
| 普通滚动层,用 JS 按滚动位置移动,让它跟着屏幕走 | 能伸进去,但滑动时会抖 |
| 普通滚动内容 | 能伸进去 |
sticky 那一行是 Safari 的取色逻辑造成的。WebKit 会在屏幕上下边缘正中往里 4px 的位置查找 fixed 或 sticky 元素(LocalFrameView::fixedContainerEdges)。找到了,就在那条栏上盖一块纯色视图(WKColorExtensionView)。这块视图在网页内容之上,颜色取自那个元素;如果取到的不止一种颜色,就退回页面背景色。
为什么 JS 会抖。 iOS 的滚动由 Safari 的界面进程直接交给系统滚动视图完成,网页里的 JS 至少要晚一帧才知道滚到了哪里。用 JS 移动图层,或者用 canvas 每帧重画,画面都会比滚动慢半拍。
滚动驱动动画不经过 JS。 CSS 的 animation-timeline: scroll() 让动画进度直接跟着滚动位置走。从 Safari 26.4 开始,这类动画在界面进程里计算:每次滚动节点更新,RemoteScrollingCoordinatorProxyIOS 就顺带更新依赖它的动画,和系统滚动同步,transform 也在可加速的属性之列。用它来移动一个普通滚动层,这个层就能像 fixed 一样停在屏幕上,同时它仍然是普通内容,能画到两条栏下面,也不会触发 Safari 的纯色盖板。
(对照页里滚动驱动动画也抖过一次,原因在测试页本身:那一层移动时把文档越撑越长,动画的目标值每一帧都在变,只能退回 JS 线程逐帧重算。)
两层嵌套。 外层负责抵消滚动,内层负责视差:
css
.article-texture-scroll-pin {
height: calc(100lvh + 2 * 120px); /* 上下各多出 120px,盖住两条栏 */
animation: pin linear both;
animation-timeline: scroll(root block);
animation-range: 0px 100000px;
}
.article-texture-scroll-layer {
animation: drift linear both;
animation-timeline: scroll(root block);
}
@keyframes pin {
from { transform: translateY(0); }
to { transform: translateY(100000px); }
}
@keyframes drift {
from { transform: translateY(32px); }
to { transform: translateY(-32px); }
}
外层的范围写成固定的 0px 到 100000px,而不是默认的整段滚动距离。这样位移恰好等于滚动距离,与页面多长无关。地址栏收起时最大滚动距离会变,默认范围会让外层慢慢错位。改成固定长度后,WebKit 换算进度和范围时用的是同一个最大值,两边正好抵消。内层的视差只有 ±32px,用默认范围就够了。
剩下几处细节:
- 这一层放在一个从文档上方开始、到文档末尾结束的容器里,并用
overflow: clip裁掉多出来的部分,所以它移动时不会把页面撑长。 - 原来的 fixed 图层和按压效果的画布在这种模式下直接隐藏,页面边缘不留 fixed 元素给 Safari 取色。
- 只在 Safari 26.4 及以上的触屏布局启用。更早的版本里这类动画还在 JS 线程上跑,会抖,于是保留原来的 fixed 层。桌面端和其它浏览器也不受影响。
做不到的部分#
停在顶部时,状态栏那一条仍是纯色而不是纹理本身,这是 Safari 的行为,网页控制不了。有一种「页面预先滚动几像素」的取巧做法能让它一开始就合成真实像素,但会干扰锚点跳转和滚动位置恢复,对阅读页不划算,没有采用。轻轻一滑,看到的就是页面本身。
小结#
viewport-fit=cover:允许页面画到安全区里;- 静止时 Safari 取贴边 fixed 元素或
html/body的背景色,把它设成和页面相接的颜色; - 不要在安全区放实心的 fixed 元素,否则滚动后真实内容也会被挡住;
- iOS 26 之后的 Safari 把 fixed 元素排在两条栏之间,竖屏下
env(safe-area-inset-*)也是 0; - 页面边缘一旦有 fixed / sticky 元素,两条栏就会被盖成纯色。要铺进栏下面的背景,只能是普通滚动内容;
- 想让它像 fixed 一样停住,用滚动驱动动画(Safari 26.4+),不要用 JS。