配了三年导航,每次吸顶都要写一坨 IntersectionObserver——今天这件事被 CSS scroll-state() 用三行彻底原生化了
粘性导航吸顶时加阴影、内容溢出时显示滚动箭头,这两个需求你以前怎么做的?IntersectionObserver 监听 + scroll 事件处理 + 一堆 class 切换。今天这件事被 CSS scroll-state() 用三行彻底原生化了——浏览器自己知道元素什么时候「卡住」了。
你的导航真的知道自己在哪个状态吗?
粘性导航吸顶之后要加个阴影,告诉你「已经置顶了」。内容区超出边界要显示上下箭头,告诉用户「这里还能滚」。这两个看似简单的需求,传统做法都要写一坨 JS:
// 粘性阴影:IntersectionObserver 监听
const observer = new IntersectionObserver(([entry]) => {
nav.classList.toggle('stuck', entry.isIntersecting);
}, { threshold: [1] });
// 滚动箭头:scroll + resize + MutationObserver 三件套
window.addEventListener('scroll', updateArrows);
const ro = new ResizeObserver(updateArrows);
ro.observe(container);
写完还得担心性能——scroll 事件在主线程上狂飙,IntersectionObserver 还好,但每个组件都这么来,页面就卡了。
今天 CSS 告诉你:浏览器比你更清楚元素现在的滚动状态,你只要问它就行了。
scroll-state() 是什么
Chrome 133 引入的 Scroll State Container Queries,是 CSS Conditional Rules Level 5 规范的一部分。思路和 Container Queries 完全一样——给一个容器声明「我要被查询」,然后在它的子元素里用 @container 问它现在的状态。
/* 告诉浏览器:这个元素需要被查询滚动状态 */
.sticky-nav {
container-type: scroll-state;
position: sticky;
top: 0;
}
查询状态有三种:
- stuck:position: sticky 的元素卡在边缘了(stuck: top / bottom / left / right)
- snapped:scroll-snap 的容器当前停在某个位置
- scrollable:容器在某个方向上还能滚动(scrollable: top / bottom / block / inline)
三行替代一坨 JS
场景一:粘性导航吸顶阴影
这是最经典的使用场景。导航置顶时加阴影,脱离时去掉阴影:
.sticky-nav {
container-type: scroll-state;
position: sticky;
top: 0;
}
/* 注意:@container 写在子元素上,不能写在同一元素 */
.sticky-nav .nav-inner {
transition: box-shadow 0.2s;
background: white;
}
@container scroll-state(stuck: top) {
.sticky-nav .nav-inner {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
}
传统方案用 IntersectionObserver,要等元素完全离开视口才能触发。你还得算 threshold,一不小心就抖。scroll-state() 的 stuck 状态在 sticky 元素刚贴上边缘那一刻就触发了,精确得多。
场景二:滚动箭头指示器
内容区超出边界时显示上/下箭头,用户一看就知道还能滚:
.scroll-panel {
container-type: scroll-state;
position: relative;
overflow-y: auto;
}
.scroll-panel .arrow {
position: absolute;
opacity: 0;
transition: opacity 0.2s;
}
/* 能向上滚时显示上箭头 */
@container scroll-state(scrollable: top) {
.scroll-panel .arrow-up {
opacity: 1;
}
}
/* 能向下滚时显示下箭头 */
@container scroll-state(scrollable: bottom) {
.scroll-panel .arrow-down {
opacity: 1;
}
}
注意 scrollable 的语义:是「还能往这个方向滚」,不是「已经滚到这个方向的最值了」。所以通常是两个方向都监听,根据箭头方向决定显示哪个。
场景三:轮播当前项高亮
结合 scroll-snap,查询当前停在哪个卡片:
.carousel {
container-type: scroll-state;
overflow-x: auto;
scroll-snap-type: x mandatory;
display: flex;
}
.carousel .card {
flex: 0 0 100%;
scroll-snap-align: center;
}
/* 当前卡片高亮——配合 JS 找到对应的 dot */
.carousel .card.is-active {
@container scroll-state(snapped: x) {
outline: 2px solid #7c6aef;
}
}
实际上 snapped 状态的查询通常需要配合 JS 来标记当前项,因为 CSS 目前没有办法直接把 snapped 状态映射到「第几张」。但比原来每次 scroll 都去计算当前索引要干净太多了。
性能这笔账
关键区别在于:谁在计算状态。
传统方案:JS 监听 scroll 事件 → 主线程执行 → class 切换 → 触发重排。
scroll-state():浏览器在合成线程上维护这些状态 → CSS 直接查询 → 样式更新 → 几乎零主线程开销。
对于滚动密集型页面(长列表、轮播、虚拟滚动),省掉 scroll 监听器的效果非常明显。Chrome DevTools Performance 面板里会看到那些昂贵的 scroll handler 消失了。
三个坑,踩过才知道疼
坑一:不能在同一个元素上查
container-type 声明在谁身上,@container 就得写在谁的后代上。写在同一个元素上不 work:
/* 这样不行 */
.sticky-nav {
container-type: scroll-state;
position: sticky;
top: 0;
@container scroll-state(stuck: top) { /* ❌ 不支持 */
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
}
坑二:Firefox / Safari 还没支持
截至 2026 年 9 月,全球覆盖率约 70%(Chromium 133+ 和 Edge 133+)。Firefox 和 Safari 还不支持。渐进增强是唯一出路:
/* 降级方案:没有 scroll-state 时保持默认样式 */
.sticky-nav .nav-inner {
box-shadow: none;
}
/* 有 scroll-state 时才加阴影 */
@supports (container-type: scroll-state) {
@container scroll-state(stuck: top) {
.sticky-nav .nav-inner {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
}
}
坑三:scrollable 不是「滚动到了某位置」
scrollable: top 的意思是「可以向上滚动」,即内容没有完全显示底部,还能往上拉。不是「已经滚到最顶部了」。想检测「已经滚到顶部」,目前还是需要 scroll 监听,但这已经比原来轻量很多。
三步下一步
第一步:找一个现有 sticky 导航
打开 Chrome 133+,F12 找到你的 position: sticky 导航,把 container-type: scroll-state 加上,试一下 stuck: top 状态。
第二步:找一个 overflow 容器加箭头
把你现有的 ResizeObserver + scroll 监听箭头逻辑替换成 scroll-state(),对比代码行数,你会回来的。
第三步:加 @supports 渐进增强
@supports (container-type: scroll-state) {
/* scroll-state 专用样式 */
}
把现在的 sticky 样式放到 @supports 里面,作为默认样式,scroll-state 规则作为增强层。这样 Firefox / Safari 用户看到的就是没有阴影和箭头的原始版本,不影响功能。
你的下一个 IntersectionObserver 大概可以删了。
评论区
登录后可评论。