文章目录

position: sticky 失效决策树:我踩的是第 4 种

position: sticky 是 CSS 里看起来最简单、实际最容易翻车的属性之一。它的文档只有几句话,但失效的场景能列出一长串。

我把它失效的原因整理成了一棵可依次排除的决策树 —— 因为凭记忆排查效率很低,而这个属性的失效原因互斥且可判定,很适合做成流程。


先说 sticky 到底需要什么

一句话:它需要有”可以粘的余地”。

sticky 的行为是:元素在它的父容器范围内滚动时,一旦触及你设的阈值(top / bottom 等),就固定住;直到父容器滚出视野,它才跟着走。

所以它成立的前提是两个:

  1. 父容器比 sticky 元素高 —— 否则没有滚动余地,粘不了。
  2. 中间没有任何祖先截断滚动 —— 否则”滚动”这件事根本传不到它。

绝大多数失效,都是这两条之一被破坏了。


决策树(按出现频率排序)

第 1 种:根本没设阈值

/* ❌ 不生效 */
.box { position: sticky; }

/* ✅ 必须配 top / bottom / left / right */
.box { position: sticky; top: 0; }

position: sticky 本身不产生任何效果 —— 它只是”允许”吸附,真正决定从哪个位置开始吸的是偏移量。这是最愚蠢但最常见的一种,检查成本也最低,所以放第一个。


第 2 种:某个祖先有 overflow(头号元凶)

只要任一祖先(不只是父级)设了 overflow: hidden / auto / scroll,sticky 就会失效。

原因:overflow 非 visible 会创建新的滚动容器,sticky 于是相对那个容器吸附,而不是相对页面 —— 而那个容器自身可能根本不滚动。

特别阴的一点:overflow-x: hidden 会连带把 overflow-y 变成 auto(CSS 规范里的计算值联动),所以为了”防横向溢出”加的一行 CSS,可能顺手废掉了所有 sticky。

诊断脚本(粘进控制台即跑):

let el = document.querySelector('.your-sticky-element');
while (el) {
  const s = getComputedStyle(el);
  if (!['visible', 'clip'].includes(s.overflow) ||
      !['visible', 'clip'].includes(s.overflowY)) {
    console.warn('截断滚动的祖先:', el, s.overflow, s.overflowY);
  }
  el = el.parentElement;
}

顺带一提:overflow: clip 是解法之一 —— 它裁剪内容但不创建滚动容器,因此不破坏 sticky。浏览器支持已相当好(Chrome 90+ / Firefox 81+ / Safari 16+)。


第 3 种:父容器不够高

sticky 只能”在整个父容器的高度范围内”吸附。如果父容器和它一样高,滚动余地为零,它永远不会粘住。

<!-- ❌ 父容器恰好就是它自己的高度,粘不了 -->
<div class="parent">
  <div class="box">sticky</div>
</div>

这在使用浮动布局时尤其常见:右栏 sticky 时,右栏的父容器高度等于右栏自身,自然没有余地。老式解法是把父容器做成 display: table-cell(这在列布局中能天然撑出等高),或者直接换成 flex / grid。


第 4 种:flex / grid 子项被拉伸 ← 我踩的就是这个

这是最容易被忽略的一种,因为它看起来”一切正常”。

.layout { display: flex; }          /* align-items 默认 stretch */
.sidebar { position: sticky; top: 0; }
/* 问题:sidebar 被拉伸到和 .layout 一样高 */
/* 高度相同 → 没有滚动余地 → 粘不了 */

flex 容器默认 align-items: normal(行为同 stretch),会把子项拉到容器高度。于是子项和父容器一样高,第 3 种的”余地不足”就复现了 —— 但这次不是父容器矮,是子项被拉高了。

解法:

.sidebar {
  position: sticky;
  top: 0;
  align-self: flex-start;   /* 关键:别拉伸,让高度回到内容高度 */
  height: auto;             /* 双保险 */
}

grid 同理,用 align-items: start 或对子项设 align-self: start。

我今天的实际教训:给三栏布局的左栏加 sticky 时没写 align-self,结果它完全不生效。而更早一次,我把 sticky 加在内层元素上(父容器是普通块级)反而生效了 —— 那时候没有 flex 拉伸问题。所以同样一行 position: sticky 换到 flex 布局里就哑了,而这不会报任何错。


第 5 种:祖先有 transform / filter / will-change

这几个属性会让祖先新建包含块与层叠上下文,sticky 于是相对那个祖先定位,表现异常。

/* ❌ 为了动画加的,顺手废掉了 sticky */
.animated-wrapper {
  transform: translateZ(0);
  will-change: transform;
  filter: blur(0);
}

排查:

let el = document.querySelector('.your-sticky-element');
while (el) {
  const s = getComputedStyle(el);
  if (s.transform !== 'none' || s.willChange !== 'auto' || s.filter !== 'none') {
    console.warn('可疑的层叠上下文祖先:', el, {
      transform: s.transform, willChange: s.willChange, filter: s.filter,
    });
  }
  el = el.parentElement;
}

解法:把 transform 移到别的元素,或者调整 DOM 让 sticky 元素不在被 transform 的祖先内。


附带一条:看得见但压不住

sticky 生效了,但滚过来的内容从它底下透出来 —— 这不是失效,是没给它不透明背景。

.sticky-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: white;   /* 必须。否则下层内容会透上来 */
}

用 border-collapse: collapse 的表格做 sticky 表头时,边框还会消失 —— 因为 collapse 模式下边框归表格所有。改用 box-shadow: 0 1px 0 #ccc 画那条线即可。


一张速查表

现象 多半是
完全不生效 没设 top/bottom(第 1 种)
完全不生效,且有布局容器 祖先 overflow(第 2 种)
完全不生效,且在 flex/grid 里 子项被 stretch(第 4 种),加 align-self: start
只在极小范围生效 父容器太矮(第 3 种)
生效但位置诡异 祖先有 transform(第 5 种)
生效但内容透出来 忘了不透明背景

一个更根本的提醒

排查 sticky 时,最有用的一步是先想清楚”谁在滚动”。

sticky 是相对最近的滚动祖先吸附的,不是相对 viewport。当页面里嵌套了多个 overflow: auto 的容器时,你以为是页面在滚,其实是内层在滚 —— 那么 sticky 的表现自然和你预期不符。

排查顺序建议:

  1. 检查有没有写 top(成本最低)
  2. 跑上面那段 overflow 祖先脚本
  3. 检查是不是 flex/grid 子项、有没有 align-self
  4. 检查父容器高度是否比它高
  5. 检查祖先有没有 transform

按这个顺序,基本能在两分钟内定位。

See Also