跳到正文

固定输入框里一打字,页面就往上跳:scroll-padding 与焦点滚动

搜索结果页往下滚了一段,顶部搜索框仍然可见。点进去补几个字,下面的结果却跟着往上跳;继续输入,页面会一步步退回顶部。

这个问题出现在一个 Nuxt 搜索页面里。起初值得怀疑的地方不少:输入会更新字符计数,筛选条件显示成标签,Header 使用 sticky,页面还接了 OverlayScrollbars。测量以后发现,搜索框的位置和尺寸一直没变,变的是文档的 scrollY。

最终修复只有一行:删除根元素上的 scroll-padding-top: 166px。问题来自文档级滚动留白与 sticky 输入框的光标滚动互相影响。其他页面若确实需要正文定位,可以把偏移配置在正文目标上。

留白改变的是滚动目标区域

scroll-padding-top 容易被当成「锚点跳转时给导航栏让一点位置」。目录定位只是它的一种用途。

CSS Scroll Snap 规范的 scroll-padding 定义说的是滚动容器的 optimal viewing region,也就是把目标带入视野时使用的区域。它适用于普通滚动容器,即使没有开启滚动吸附也会参与;设置在根元素上时,会应用到文档视口。

这里的「留白」不会给盒子加内边距,也不会改变元素实际是否可见。它会参与判断元素或光标是否已经滚入目标区域,包括聚焦时的滚动。

这就解释了两个看起来矛盾的事实:输入框明明能看见,浏览器仍可能尝试滚动页面;输入框没有发生布局变化,列表仍然会移动。

触发问题的结构可以缩成下面这段等价 CSS:

/* 文档视口顶部留出 166px,但输入框本身也停留在这段区域里。 */
:root {
  scroll-padding-top: 166px;
}

.search-header {
  position: sticky;
  top: 0;
}

滚动到 Header 吸顶以后,输入框仍然停在视口顶部。按键引起光标位置更新,浏览器尝试把光标放进留白以外的目标区域,于是改变文档滚动位置。可是 Header 仍被 sticky 固定住,光标在视口里的位置没有随正文一起改变。下一个按键又会碰到同样的条件。

这里的固定指 sticky 吸顶。上面的解释对应本次验证的结构与浏览器表现,不把每次滚动的距离当成规范规定的算法。

Chromium issue 374034263记录过相同组合:根元素有 scroll-padding-top,sticky 区域内有可聚焦控件,按键会让页面向上滚动。记录中的确认意见也把原因指向了按键触发的 scroll-into-view。规范说明属性会影响光标滚动,缺陷记录和本机实验则说明这个组合具体怎样失效。

用原生页面做一次对照

为了排除业务状态和第三方库的影响,我把场景缩成原生 HTML、CSS 与 JS:一个 sticky 搜索框、40 条静态资料、一个切换根元素滚动留白的复选框。页面只读取 scrollY,输入事件不会写回滚动位置。

在 Windows、Chrome 155.0.8059.85、1280 × 800 CSS 像素视口下,先滚到 1100px,再点击输入框并逐字输入,得到下面这组数据。两组之间只改变根元素的滚动留白。

动作 根元素留白 166px 去掉根元素留白 输入框 top
滚动后 1100 1100 41px
点击聚焦后 680 1100 41px
输入第一个字 567 1100 41px
输入第二个字 454 1100 41px
输入第六个字 2 1100 41px

中间两列是文档的 scrollY,最后一列是输入框相对视口的 getBoundingClientRect().top。留白开启时,连续按键每次让文档上移约 113px,输入框却一直停在 41px。原项目里的输入框位置不同,测到的跳动幅度约为 127px;这些都是特定布局下的实测值。

下面是最小复现页连续输入六个字符后的真实截图。开启全局留白时,原本滚到 1100px 的列表已接近顶部,右下角读数为 2。

全局留白开启时,连续输入后文档接近顶部

去掉根元素留白后,执行同样的操作,列表仍停留在原来的位置,读数保持 1100。

去掉根元素留白后,输入过程中滚动位置保持 1100

390px 宽度下也做了同样的对照。这里模拟的是手机宽度,没有覆盖真实手机软键盘,也没有据此宣布 Safari、Firefox 或所有 WebView 都有相同表现。完整阶段数据保存在浏览器观察记录中。

可以在下面的预览里切换配置。先在预览内部向下滚,再输入文字;如果当前浏览器不再复现,以实际读数为准。

读取 /files/sticky-input-scroll-padding-focus-jump/kits/sticky-input-scroll-guard/ 的 README.md、MANIFEST.json 和 FILES.json。
按索引查看复现页、Stylelint 配置和浏览器检查,将滚动位置断言迁移到项目真实入口。
滚动后在固定搜索框输入正在加载代码工作区...

修复时把滚动偏移放回需要它的地方

这次搜索页没有需要文档级统一偏移的锚点流程。因此,直接从全局 Stylus 中删除这一项,保留原来的 Header 与原生输入行为:

 :root
-  scroll-padding-top 166px

如果正文确实需要定位偏移,可以把需求表达在正文目标上。例如,目录链接会跳到文章标题,就只给这些标题设置 scroll-margin-block-start:

/* 只影响正文标题作为滚动目标时的定位区域。 */
.article-heading[id] {
  scroll-margin-block-start: 166px;
}

这个属性放在被定位的目标上,不会自动改变 sticky 搜索框的滚动区域。偏移值仍要按实际 Header 高度与断点设计;不能把这里的 166px 当成通用尺寸。规范对 scroll-margin 的定义也说明了它怎样影响目标的滚动定位范围。

另一类页面本来就有独立的内容滚动容器:搜索区在容器外,正文在容器内。此时可以把 scroll-padding 放在那个内容容器上。普通文档不必为了避开这一个问题,就额外引入一套嵌套滚动。

这些替代写法适用于确有定位需求的页面,不是这次应用修复额外引入的代码。修改后仍要检查目录跳转、Tab 焦点和正文目标是否被头部遮挡。

只给 focus 加 preventScroll 还不够

input.focus({ preventScroll: true }) 能约束这一次程序聚焦,但随后按键引起的光标滚动仍然可能发生。在同一最小复现里,保留全局留白并这样聚焦,结果是:

[
  { "stage": "滚动后", "scrollY": 1100 },
  { "stage": "聚焦后", "scrollY": 1100 },
  { "stage": "输入 1", "scrollY": 987 },
  { "stage": "输入 2", "scrollY": 874 }
]

这段是观察记录中的桌面场景节选,只保留阶段和 scrollY。聚焦时没有跳,输入时仍然跳。因此,给现有 focus() 补一个选项,并没有处理这次冲突的来源。

同样不适合用每次输入后写回旧 scrollTop 来处理:那会让应用和浏览器各自修改滚动位置,还需要处理选区、输入法和软键盘。这个方案没有用于本次修复。

静态告警提醒复核,浏览器检查负责确认

这个问题能沉淀成检查规则,但静态工具掌握的信息有限。scroll-padding 是合法能力:只有正文、导航栏没有输入框的文章页,就可能需要全局留白。仅凭一段 CSS,很难知道最终哪个控件吸顶、光标在哪里、变量解析成多少像素。

因此,这里提供的是选择加入的项目策略:对明确的 html、:root 声明产生 warning,提醒开发者复核固定输入框。它复用 Stylelint 内置的 rule-selector-property-disallowed-list,不需要再写一个同名职责的插件规则。

源码包里的配置同时覆盖独立根选择器和 :root, .theme 这类选择器列表,检查属性包括简写及逻辑属性:

// 合并进现有 Stylelint rules;完整配置见上面的源码工作区。
{
  'rule-selector-property-disallowed-list': [
    { '/(?:^|,)\\s*(?:html|:root)\\s*(?=,|$)/i': ['/^scroll-padding(?:-|$)/'] },
    { severity: 'warning' },
  ],
}

这条策略只按选择器和属性判断,scroll-padding-top: 0 也会提醒。局部内容容器和正文的 scroll-margin 不受它限制;html[data-theme]、嵌套别名、JS 动态样式则属于当前配置的漏检边界。Vue SFC、Stylus、SCSS 等文件还需要项目现有的解析或编译流程。不能把 warning 当成已确认的浏览器缺陷,也不能把没有 warning 当成稳定性证明。

已经核实的合法用法,可以用带原因的局部禁用注释保留;这条检查不提供自动删除样式的修复。源码包包含 10 个配置用例,分别验证应提醒与应放过的范围。

运行时的验证则应保留一个直接的不变量:用户只是在固定输入框中编辑时,原先的正文滚动位置保持不变。

不能只在首屏输入,也不能只用 fill() 一次性替换值。本次检查先让页面产生足够长的内容,向下滚动,再点击聚焦、逐字输入、清空、粘贴和中间插入,每一步读取真实 scrollY。真实应用还验证了快捷键聚焦与条件标签删除,在 1100px 和 2200px 位置都保持稳定。输入法组合阶段、真实软键盘和其他浏览器,继续按目标产品的兼容范围补测。

总结

这次输入跳动时,搜索框的尺寸和视口位置没有变化,浏览器却因根元素滚动留白与 sticky 光标定位的冲突反复滚动文档。去掉不需要的全局 scroll-padding-top 后,原生对照页和真实应用中的滚动位置恢复稳定;仅限制一次 focus(),不能阻止后续按键触发的光标滚动。

固定 Header 的视觉稳定与文档滚动稳定要分别检查。遇到类似跳动,先同时量 DOMRect 与真实 scrollTop,再核对全局滚动留白、焦点和光标的定位范围;确有正文定位需求时,再按目标或内容容器配置偏移。静态 warning 只负责提示复核,输入法、真实软键盘和其他浏览器仍需按产品范围补测。