起因
要做一个商品轮播 + 滑到中间自动对齐 + 触屏滑动也丝滑。
传统做法用 swiper.js / react-slick 等库,bundle 几十 KB + 复杂 API。
scroll-snap CSS 几行原生实现,所有现代浏览器全支持,零 JS。
1. 横向轮播
<div class="carousel">
<div class="card">A</div>
<div class="card">B</div>
<div class="card">C</div>
<div class="card">D</div>
</div>
<style>
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory; /* 横向 + 强制 snap */
scroll-padding: 16px;
gap: 16px;
padding: 16px;
}
.card {
flex: 0 0 80%; /* 每个卡片占 80% 视口宽 */
scroll-snap-align: center; /* 滑动停在卡片中心 */
background: #eee;
height: 200px;
border-radius: 12px;
}
</style>
效果:
- 横向滑动 / swipe
- 松手后自动对齐到最近卡片中心
- 触屏 momentum 滚动 + snap 一起 work
- 鼠标 wheel / 键盘箭头都 work
完全 CSS。
2. mandatory vs proximity
scroll-snap-type: x mandatory; /* 强制:松手必停 snap 点 */
scroll-snap-type: x proximity; /* 接近:靠近 snap 点才 snap */
mandatory 适合"看一个" 的轮播;proximity 适合"长列表偶尔对齐" 场景。
3. snap-align 选项
scroll-snap-align: start; /* 元素左边对齐 */
scroll-snap-align: center; /* 元素居中对齐 */
scroll-snap-align: end; /* 元素右边对齐 */
4. 全屏式纵向"翻页"
<main class="pages">
<section>第一屏</section>
<section>第二屏</section>
<section>第三屏</section>
</main>
<style>
html { scroll-snap-type: y mandatory; } /* 整页 snap */
section {
height: 100vh;
scroll-snap-align: start;
}
</style>
或者 mandatory 在 html 上:
html, body {
height: 100%;
scroll-snap-type: y mandatory;
}
section {
scroll-snap-align: start;
min-height: 100vh;
}
效果:滚轮 / 滑动按"屏" 翻页,落地页常用。
5. snap-stop:必须 stop 在某些元素
.important-card {
scroll-snap-align: center;
scroll-snap-stop: always; /* 不允许"飞越"这个元素 */
}
普通卡片可以滚很远跳过;always 强制每个都停。
高 momentum 滑动时 prevent 飞过去。
6. 进度指示器(dot / progress)
scroll-snap 本身没有"当前第几页" 信息。要做 indicator dot:
function Carousel({ items }) {
const ref = useRef<HTMLDivElement>(null)
const [active, setActive] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
const idx = Array.from(el.children).indexOf(e.target as HTMLElement)
setActive(idx)
}
})
},
{ root: el, threshold: 0.5 }
)
Array.from(el.children).forEach(c => observer.observe(c))
return () => observer.disconnect()
}, [])
return (
<>
<div ref={ref} className="carousel">
{items.map(item => <div key={item.id} className="card">{item.title}</div>)}
</div>
<div className="dots">
{items.map((_, i) => (
<span className={i === active ? 'active' : ''} />
))}
</div>
</>
)
}
IntersectionObserver 检测哪个卡片当前可见。
没 JS 也能 snap,只是没 indicator。
7. 前进 / 后退按钮
function Carousel({ items }) {
const ref = useRef<HTMLDivElement>(null)
function scroll(dir: number) {
const el = ref.current
if (!el) return
const cardWidth = el.firstElementChild?.clientWidth ?? 0
el.scrollBy({ left: cardWidth * dir, behavior: 'smooth' })
}
return (
<>
<button onClick={() => scroll(-1)}>←</button>
<button onClick={() => scroll(1)}>→</button>
<div ref={ref} className="carousel">
{items.map(...)}
</div>
</>
)
}
scrollBy + smooth 程序触发滚动 + snap 自动配合。
8. 移动端 momentum
iOS Safari 需要:
.carousel {
-webkit-overflow-scrolling: touch; /* 老 iOS 启 momentum */
}
现代 iOS 默认 momentum,但写上也无害。
9. 隐藏 scrollbar
视觉上不要 scrollbar 但仍可滚:
.carousel {
/* Firefox */
scrollbar-width: none;
/* Chrome / Safari */
&::-webkit-scrollbar { display: none; }
}
完整示例:商品轮播
<div class="product-carousel">
<div class="product">商品 1</div>
<div class="product">商品 2</div>
<div class="product">商品 3</div>
<div class="product">商品 4</div>
<div class="product">商品 5</div>
</div>
<style>
.product-carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 12px;
padding: 16px;
scrollbar-width: none;
}
.product-carousel::-webkit-scrollbar { display: none; }
.product {
flex: 0 0 calc(50% - 18px); /* 移动端每屏 2 个 */
scroll-snap-align: start;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px;
}
@media (min-width: 768px) {
.product {
flex: 0 0 calc(25% - 12px); /* 桌面每屏 4 个 */
}
}
</style>
响应式 + snap + 美观 < 30 行 CSS。
11. 跟 Swiper.js 对比
| scroll-snap CSS | Swiper.js | |
|---|---|---|
| bundle | 0 | 30-100 KB |
| 学习成本 | 极低(CSS 属性) | 中(API + plugin) |
| 灵活度 | 中(CSS 限制) | 极高(事件 / 插件) |
| 动画 / parallax | 困难 | 简单 |
| autoplay | 自己写 JS | 内置 |
| nested swiper | 难 | 支持 |
| 多 breakpoint | media query | 内置 |
简单轮播 / 卡片流 → scroll-snap。
复杂动画 / autoplay / parallax / 大量 feature → Swiper。
12. 浏览器支持
scroll-snap 全 evergreen 浏览器支持(Safari 11+ / Firefox 39+ /
Chrome 69+ / Edge 79+)。
不需要 polyfill。
老 IE 不支持但本来也没人 care。
实战 use case
我们网站几处用 scroll-snap 后:
- 删除 swiper.js 依赖 → bundle 减 60 KB
- 触屏滑动体验跟原生 app 一致
- 维护成本 0(CSS 一直 work)
不适合:
- 复杂 carousel(autoplay / parallax / cube transition)
- 多嵌套 swiper
踩过的坑
-
scroll-snap-type 在父,align 在子:写反了不 work。
-
flex item 没设
flex: 0 0 ...→ 自动收缩 → 看起来都对齐
但宽度不对。强制flex-shrink: 0。 -
mobile 触屏 snap 不灵敏:低端 Android 偶尔 snap 慢。
设scroll-snap-stop: always让必停每个。 -
snap 让锚点跳乱:含
#anchorURL 时 snap 偶尔覆盖 scroll-to-anchor。
配scroll-padding-top给固定 header 留位置。 -
iOS Safari momentum 衰减:iOS 上 momentum 后才 snap,感觉
slightly delayed。这是 OS 行为,无解。
登录后参与评论。