CSS scroll-snap:用 CSS 写"翻页式"滚动(轮播 / 卡片流)

起因

要做一个商品轮播 + 滑到中间自动对齐 + 触屏滑动也丝滑。
传统做法用 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

踩过的坑

  1. scroll-snap-type 在父,align 在子:写反了不 work。

  2. flex item 没设 flex: 0 0 ... → 自动收缩 → 看起来都对齐
    但宽度不对。强制 flex-shrink: 0

  3. mobile 触屏 snap 不灵敏:低端 Android 偶尔 snap 慢。
    scroll-snap-stop: always 让必停每个。

  4. snap 让锚点跳乱:含 #anchor URL 时 snap 偶尔覆盖 scroll-to-anchor。
    scroll-padding-top 给固定 header 留位置。

  5. iOS Safari momentum 衰减:iOS 上 momentum 后才 snap,感觉
    slightly delayed。这是 OS 行为,无解。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。