position: sticky 是 CSS 里看起来最简单、实际最容易翻车的属性之一。它的文档只有几句话,但失效的场景能列出一长串。
我把它失效的原因整理成了一棵可依次排除的决策树 —— 因为凭记忆排查效率很低,而这个属性的失效原因互斥且可判定,很适合做成流程。
先说 sticky 到底需要什么
一句话:它需要有”可以粘的余地”。
sticky 的行为是:元素在它的父容器范围内滚动时,一旦触及你设的阈值(top / bottom 等),就固定住;直到父容器滚出视野,它才跟着走。
所以它成立的前提是两个:
- 父容器比 sticky 元素高 —— 否则没有滚动余地,粘不了。
- 中间没有任何祖先截断滚动 —— 否则”滚动”这件事根本传不到它。
绝大多数失效,都是这两条之一被破坏了。
决策树(按出现频率排序)
第 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 的表现自然和你预期不符。
排查顺序建议:
- 检查有没有写
top(成本最低) - 跑上面那段
overflow祖先脚本 - 检查是不是 flex/grid 子项、有没有
align-self - 检查父容器高度是否比它高
- 检查祖先有没有
transform
按这个顺序,基本能在两分钟内定位。