组件
横向滚动转换 Horizontal Scroll
将用户的垂直滚动行为(Page Scroll)无缝映射并锁定转换为宽画幅横向轨道平移的视差布局组件。
基础用法
随着用户向下滚动,页面在当前区域自动停靠(Pinning),并将滚动距离等比转换为横向卡片轨道的平滑位移:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/horizontal-scroll安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/horizontal-scroll.tsx"use client"
import * as React from "react"
import {
motion,
useReducedMotion,
useScroll,
useSpring,
useTransform,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
export interface HorizontalScrollProps extends Omit<
HTMLMotionProps<"section">,
"children"
> {
/** Wide content translated horizontally while the section is pinned. */
children: React.ReactNode
/** Scrollable element to observe instead of the page. */
container?: React.RefObject<HTMLElement | null>
/** Extra vertical scrolling distance in pixels. @default 0 */
scrollPadding?: number
/** Ease the track through a spring so wheel steps glide instead of jump. @default true */
smooth?: boolean
/** Classes applied to the pinned viewport. */
viewportClassName?: string
/** Classes applied to the horizontally translated track. */
trackClassName?: string
}
interface HorizontalMetrics {
distance: number
viewportHeight: number
}
function getViewportHeight(container?: HTMLElement | null) {
if (!container) return window.innerHeight
const style = window.getComputedStyle(container)
return (
container.clientHeight -
parseFloat(style.paddingTop) -
parseFloat(style.paddingBottom)
)
}
/** Converts vertical section progress into a pinned horizontal track. */
function HorizontalScroll({
children,
container,
scrollPadding = 0,
smooth = true,
className,
viewportClassName,
trackClassName,
style,
...props
}: HorizontalScrollProps) {
const sectionRef = React.useRef<HTMLElement>(null)
const trackRef = React.useRef<HTMLDivElement>(null)
const reduceMotion = useReducedMotion()
const [metrics, setMetrics] = React.useState<HorizontalMetrics>({
distance: 0,
viewportHeight: 0,
})
const { scrollYProgress } = useScroll({
target: sectionRef,
container,
offset: ["start start", "end end"],
})
const springProgress = useSpring(scrollYProgress, {
stiffness: 180,
damping: 32,
mass: 0.35,
restDelta: 0.0005,
})
const progress = smooth ? springProgress : scrollYProgress
const x = useTransform(progress, [0, 1], [0, -metrics.distance])
React.useLayoutEffect(() => {
const section = sectionRef.current
const track = trackRef.current
if (!section || !track) return
const measure = () => {
setMetrics({
distance: Math.max(track.scrollWidth - section.clientWidth, 0),
viewportHeight: getViewportHeight(container?.current),
})
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(section)
observer.observe(track)
if (container?.current) observer.observe(container.current)
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
window.removeEventListener("resize", measure)
}
}, [container, reduceMotion])
if (reduceMotion) {
return (
<motion.section
ref={sectionRef}
data-slot="horizontal-scroll"
className={cn("overflow-x-auto", className)}
style={style}
{...props}
>
<div
ref={trackRef}
data-slot="horizontal-scroll-track"
className={cn("flex w-max", trackClassName)}
>
{children}
</div>
</motion.section>
)
}
const sectionHeight =
metrics.viewportHeight + metrics.distance + scrollPadding
return (
<motion.section
ref={sectionRef}
data-slot="horizontal-scroll"
className={cn("relative", className)}
style={{ ...style, height: sectionHeight || undefined }}
{...props}
>
<div
data-slot="horizontal-scroll-viewport"
className={cn("sticky top-0 overflow-hidden", viewportClassName)}
style={{ height: metrics.viewportHeight || "100vh" }}
>
<motion.div
ref={trackRef}
data-slot="horizontal-scroll-track"
className={cn(
"flex h-full w-max will-change-transform",
trackClassName
)}
style={{ x }}
>
{children}
</motion.div>
</div>
</motion.section>
)
}
export { HorizontalScroll }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 在横向轨道中排布的宽幅卡片、案例展示块或分段时间线内容。 |
| container | React.RefObject<HTMLElement | null> | — | 局部可滚动容器的 ref 引用。若在整个页面中使用则无需传递该属性。 |
| scrollPadding | number | 0 | 额外附加的垂直虚拟滚动距离(像素),用于在横向轨道滚动完毕后增加停留缓冲时间。 |
| smooth | boolean | true | 通过弹簧缓动轨道位移,让鼠标滚轮的离散步进变为连续滑行。 |
| viewportClassName | string | — | 应用于吸顶固定视口(Sticky Viewport)的 CSS 类名。 |
| trackClassName | string | — | 应用于横向平移轨道(Translated Track)的 CSS 类名。 |
| className | string | — | 应用于最外层总高度包装容器的 CSS 类名。 |
使用场景与设计规范
HorizontalScroll 适用于时间线路线图(Roadmap)、横向架构拆解图、多卡片案例研读:
- 自适应距离计算:组件利用
ResizeObserver动态量测track.scrollWidth与视口宽度的真实差值,自动计算出最精确的垂直占位总高度,无需硬编码滚动像素。 - 局部容器:传入
container时,吸顶视口高度取容器的内容区高度(已扣除上下内边距)。 - 免除横向滚轮困扰:大多数桌面端鼠标只有纵向滚轮,通过将纵向滚动无缝映射为横向位移,极大降低了用户探索宽幅内容的交互门槛。
场景示例
产品路线图
沿时间线横向浏览各季度计划,scrollPadding 在轨道走完后留出一段停留:
Loading…
无障碍与交互 Accessibility
- 自动降级为原生横向滚动:在开启
prefers-reduced-motion: reduce的系统中,组件将不会劫持垂直滚动高度,而是直接降级为标准的overflow-x: auto原生滚动条容器,保证完全可访问。 - 键盘导航兼容:横向轨道内的卡片依然按 DOM 顺序排布,使用 Tab 键可以在各个卡片内的按钮、链接之间正常顺畅聚焦。