wui
组件

滚动吸附 Scroll Snap

基于原生 CSS Scroll Snap 规范构建的高性能全屏或横向分段吸附容器组件。

基础用法

垂直分屏吸附,每次滚动对齐到下一章。通过 onActiveChange 拿到当前章节,驱动右侧指示器与文案入场动画:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/scroll-snap
安装基础依赖与动效库
pnpm add lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/scroll-snap.tsx
components/ui/scroll-snap.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface ScrollSnapProps extends React.ComponentProps<"div"> {
  /** Snap axis. @default "y" */
  axis?: "x" | "y"
  /** Whether the browser must always settle on a snap point. @default "mandatory" */
  strictness?: "mandatory" | "proximity"
  /** Hide native scrollbars while preserving scrolling. @default false */
  hideScrollbar?: boolean
  /** Called with the index of the item currently aligned to the snap edge. */
  onActiveChange?: (index: number) => void
}

export interface ScrollSnapItemProps extends React.ComponentProps<"section"> {
  /** Position used when the item becomes the active snap point. @default "start" */
  align?: "start" | "center" | "end"
  /** Prevent fast scrolling from skipping this item. @default false */
  stop?: boolean
}

function getActiveIndex(root: HTMLElement, axis: "x" | "y") {
  const items = Array.from(
    root.querySelectorAll<HTMLElement>(':scope > [data-slot="scroll-snap-item"]')
  )
  if (items.length === 0) return -1

  const scrollStart = axis === "y" ? root.scrollTop : root.scrollLeft
  const viewport = axis === "y" ? root.clientHeight : root.clientWidth
  const scrollSize = axis === "y" ? root.scrollHeight : root.scrollWidth
  if (scrollStart + viewport >= scrollSize - 1) return items.length - 1

  const rootRect = root.getBoundingClientRect()
  let active = 0
  let closest = Number.POSITIVE_INFINITY
  items.forEach((item, index) => {
    const rect = item.getBoundingClientRect()
    const distance = Math.abs(
      axis === "y" ? rect.top - rootRect.top : rect.left - rootRect.left
    )
    if (distance < closest) {
      closest = distance
      active = index
    }
  })
  return active
}

/** A native CSS scroll-snap container for page-like or horizontal sections. */
function ScrollSnap({
  axis = "y",
  strictness = "mandatory",
  hideScrollbar = false,
  onActiveChange,
  className,
  style,
  ref,
  onScroll,
  ...props
}: ScrollSnapProps) {
  const rootRef = React.useRef<HTMLDivElement | null>(null)
  const activeRef = React.useRef(-1)
  const frameRef = React.useRef(0)
  const callbackRef = React.useRef(onActiveChange)
  callbackRef.current = onActiveChange

  const update = React.useCallback(() => {
    const root = rootRef.current
    if (!root || !callbackRef.current) return
    const next = getActiveIndex(root, axis)
    if (next !== activeRef.current && next >= 0) {
      activeRef.current = next
      callbackRef.current(next)
    }
  }, [axis])

  React.useEffect(() => {
    update()
    return () => cancelAnimationFrame(frameRef.current)
  }, [update])

  return (
    <div
      ref={(node) => {
        rootRef.current = node
        if (typeof ref === "function") return ref(node)
        if (ref) ref.current = node
      }}
      data-slot="scroll-snap"
      data-axis={axis}
      className={cn(
        "overscroll-contain",
        axis === "y" ? "overflow-y-auto" : "overflow-x-auto",
        hideScrollbar && "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
        className
      )}
      style={{ ...style, scrollSnapType: `${axis} ${strictness}` }}
      onScroll={(event) => {
        if (onActiveChange) {
          cancelAnimationFrame(frameRef.current)
          frameRef.current = requestAnimationFrame(update)
        }
        onScroll?.(event)
      }}
      {...props}
    />
  )
}

/** One semantic snap point inside ScrollSnap. */
function ScrollSnapItem({
  align = "start",
  stop = false,
  className,
  style,
  ...props
}: ScrollSnapItemProps) {
  return (
    <section
      data-slot="scroll-snap-item"
      className={cn("shrink-0", className)}
      style={{
        ...style,
        scrollSnapAlign: align,
        scrollSnapStop: stop ? "always" : "normal",
      }}
      {...props}
    />
  )
}

export { ScrollSnap, ScrollSnapItem }

属性 Props

ScrollSnap

属性类型默认值说明
axis"x" | "y""y"吸附主轴方向。`y` 为垂直分屏吸附,`x` 为横向卡片吸附。
strictness"mandatory" | "proximity""mandatory"吸附严格程度。`mandatory` 强制每次滚动停靠在吸附点;`proximity` 仅在接近吸附点时发生吸附。
hideScrollbarbooleanfalse是否隐藏原生滚动条 UI,同时保留完整的滚轮与触摸滑动能力。
onActiveChange(index: number) => void—当前对齐到吸附边缘的子项索引变化时触发。滚动到末端时返回最后一项。仅在传入时才会监听滚动。
classNamestring—应用于吸附滚动容器的 CSS 类名。

ScrollSnapItem

属性类型默认值说明
align"start" | "center" | "end""start"当前卡片在对齐到吸附容器时的对齐锚点。
stopbooleanfalse是否防止快速快速滑动时跳过当前吸附点(开启后 `scroll-snap-stop: always`)。
classNamestring—应用于单张吸附子项的 CSS 类名。

事件 Events

属性类型默认值说明
onScroll(event: React.UIEvent<HTMLDivElement>) => void—原生滚动事件监听函数。

使用场景与设计规范

ScrollSnap 适用于全屏幻灯片、移动端横向滑块、财务仪表盘指标卡轮播:

  • 原生性能:吸附完全由浏览器原生 CSS scroll-snap-type 与 overscroll-behavior 处理,触控与惯性保持系统手感;onActiveChange 使用 requestAnimationFrame 节流,只在索引变化时回调。
  • 防止过冲(scroll-snap-stop: always):对关键章节配置 stop={true},确保用户即使用力猛滑也不会漏掉核心信息。

场景示例

横向商品列表

水平滑动浏览商品,结合 onActiveChange 显示当前位置并控制前后切换按钮:

Loading…

无障碍与交互 Accessibility

  • 键盘导航友好:用户可以使用键盘方向键 ↑ / ↓ / ← / → 以及 PageUp / PageDown 逐页对齐切换。
  • 触控手势原生支持:在 iOS 与 Android 触控屏上完美保留弹性回弹(Rubber-banding)物理直觉。