← 📜 Scroll · 滚动怎么动、长什么样、装得下多少 / CSS Scroll Snap:让滚动整齐地停靠 待审核 6 / 9
scroll snap · 吸附

CSS Scroll Snap:让滚动整齐地停靠

横向 carousel、整屏分页、图片画廊——希望滚动结束时停在整齐的位置,而不是卡在两张图之间。CSS Scroll Snap 让滚动在结束时自动吸附到预设的对齐点,纯 CSS,不必用 JS 监听 scroll 反复计算与 scrollTo

1 · 三个角色,五个属性

整套机制围绕三个角色展开——容器、容器里的对齐基准区、子项各自的吸附区:

属性 设在 作用
scroll-snap-type snap-container(滚动容器) 开启吸附,并选(x / y / block / inline / both)与强度(mandatory / proximity)。
scroll-padding snap-container 把容器可视区内缩snapport(对齐基准区),给固定栏 / 留白让位。
scroll-snap-align snap-area(子项) 子项用 start / center / end 哪个点去对齐 snapport;none 让该子项不参与吸附。
scroll-margin snap-area(子项) 把子项边框盒外扩成它的 snap-area,微调吸附判定与落点。
scroll-snap-stop snap-area(子项) normal = 一次手势可掠过多个吸附点;always = 遇到吸附点就必须停,不许划过。

术语:snapport = 容器可视区被 scroll-padding 内缩后的框,吸附时子项的对齐点要落到它的边 / 中心;snap-area = 子项边框盒被 scroll-margin 外扩后的框,scroll-snap-align 决定用它的哪个点。

横向拖动 / 滚动下框,松手看它吸附到哪。改各项参数再试——尤其留意 mandatoryproximity 的手感差别,以及 scroll-padding(容器内缩,虚线)与 scroll-margin(子项外扩)如何挪动吸附落点。

3 · scroll-snap-align 的完整写法

它不止 start 一个值,实际可写一到两个、还能退出:

.item   { scroll-snap-align: center; }
.item-2 { scroll-snap-align: start center; }
.item-x { scroll-snap-align: none; }
  • 单值(如 center):block 与 inline 两轴都用这个对齐。
  • 双值(如 start center):block 轴在前、inline 轴在后,两轴分别对齐。
  • none:该子项作为吸附点——同一容器里混着设,可让大多数子项吸附、个别子项自由停。

4 · mandatory 还是 proximity

  • mandatory:滚动结束总是停在最近的吸附点——整齐,但内容比视口高 / 宽时,夹在两个吸附点之间的部分可能无法停留查看。适合「每屏一页」「每次一张」的场景。
  • proximity:只有滚动停在吸附点附近时才吸附,否则保留用户停的位置——更宽容,适合长内容画廊。不确定就用它。

5 · 纵向整屏分页

把轴换成 y、让每个 section 占满容器高度(height: 100%)、scroll-snap-align: start,就得到「一屏一页」的纵向分页(短视频流、落地页分屏都是这个套路)。下框纵向滚动试试:

轴关键字:x / y 是物理轴,block / inline 是逻辑轴(随 writing-mode 走),both 让两轴同时吸附(网格画廊)。

6 · 用事件知道「吸到了谁」

过去要知道当前停在第几张,只能监听 scroll 自己算 scrollLeft。现在有两个原生事件直接在滚动容器上触发,事件对象(SnapEvent)带 snapTargetBlock / snapTargetInline,直接给出吸附到的子元素(上面的横向 carousel demo 已用它读出「吸附到」与「滚动中预选」,并在 readout 里标出你的浏览器是否支持):

  • scrollsnapchanging:滚动过程中,预选的吸附目标变化时触发(上面 demo 的「滚动中预选」)。
  • scrollsnapchange:滚动结束、吸附目标最终确定时触发(上面 demo 的「吸附到」),紧挨在 scrollend 之前。
const box = document.querySelector('.carousel');

box.addEventListener('scrollsnapchange', (e) => {
  e.snapTargetInline.classList.add('active');
});

box.addEventListener('scrollsnapchanging', (e) => {
  preview(e.snapTargetInline);
});

7 · 纯 CSS carousel:圆点指示器与翻页按钮

更进一步,圆点指示器左右翻页按钮过去都得写 JS,如今也能纯 CSS 生成:容器设 scroll-marker-group: after 自动在其后生成一组标记,每个子项的 ::scroll-marker 是一个标记(点),当前项用 :target-current 高亮;::scroll-button() 生成可点的上一张 / 下一张按钮。下面这排圆点是纯 CSS、点它能跳页,没有一行 JS:

.carousel {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-marker-group: after;
}
.carousel > li { scroll-snap-align: center; }

.carousel > li::scroll-marker {
  content: "";
  width: 12px; height: 12px; border-radius: 50%;
  background: #fff; border: 2px solid #bac8ff;
}
.carousel > li::scroll-marker:target-current {
  background: #4263eb;
}

.carousel::scroll-button(left)  { content: "‹" / "上一张"; }
.carousel::scroll-button(right) { content: "›" / "下一张"; }
.carousel::scroll-button(*):disabled { opacity: .35; }

8 · 边界与细节

  • 吸附发生在滚动结束那一刻,不打断滚动过程;scroll-snap-type 设在容器、对齐属性设在子项,缺一不可。
  • mandatory 配上「比视口大的子项」要谨慎——中间内容可能永远滚不到、停不住。
  • scrollsnapchange / scrollsnapchanging(Chrome 129+)与 ::scroll-marker / ::scroll-button()(Chrome 135+)较新,需做渐进增强:不支持时退回手算 scroll、退回普通滚动容器(无圆点 / 按钮),核心吸附照常工作。