组件
滚动吸附 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"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` 仅在接近吸附点时发生吸附。 |
| hideScrollbar | boolean | false | 是否隐藏原生滚动条 UI,同时保留完整的滚轮与触摸滑动能力。 |
| onActiveChange | (index: number) => void | — | 当前对齐到吸附边缘的子项索引变化时触发。滚动到末端时返回最后一项。仅在传入时才会监听滚动。 |
| className | string | — | 应用于吸附滚动容器的 CSS 类名。 |
ScrollSnapItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| align | "start" | "center" | "end" | "start" | 当前卡片在对齐到吸附容器时的对齐锚点。 |
| stop | boolean | false | 是否防止快速快速滑动时跳过当前吸附点(开启后 `scroll-snap-stop: always`)。 |
| className | string | — | 应用于单张吸附子项的 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)物理直觉。