wui
组件

横向滚动转换 Horizontal Scroll

将用户的垂直滚动行为(Page Scroll)无缝映射并锁定转换为宽画幅横向轨道平移的视差布局组件。

第三方依赖 · motion

基础用法

随着用户向下滚动,页面在当前区域自动停靠(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
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

属性类型默认值说明
childrenReact.ReactNode—在横向轨道中排布的宽幅卡片、案例展示块或分段时间线内容。
containerReact.RefObject<HTMLElement | null>—局部可滚动容器的 ref 引用。若在整个页面中使用则无需传递该属性。
scrollPaddingnumber0额外附加的垂直虚拟滚动距离(像素),用于在横向轨道滚动完毕后增加停留缓冲时间。
smoothbooleantrue通过弹簧缓动轨道位移,让鼠标滚轮的离散步进变为连续滑行。
viewportClassNamestring—应用于吸顶固定视口(Sticky Viewport)的 CSS 类名。
trackClassNamestring—应用于横向平移轨道(Translated Track)的 CSS 类名。
classNamestring—应用于最外层总高度包装容器的 CSS 类名。

使用场景与设计规范

HorizontalScroll 适用于时间线路线图(Roadmap)、横向架构拆解图、多卡片案例研读:

  • 自适应距离计算:组件利用 ResizeObserver 动态量测 track.scrollWidth 与视口宽度的真实差值,自动计算出最精确的垂直占位总高度,无需硬编码滚动像素。
  • 局部容器:传入 container 时,吸顶视口高度取容器的内容区高度(已扣除上下内边距)。
  • 免除横向滚轮困扰:大多数桌面端鼠标只有纵向滚轮,通过将纵向滚动无缝映射为横向位移,极大降低了用户探索宽幅内容的交互门槛。

场景示例

产品路线图

沿时间线横向浏览各季度计划,scrollPadding 在轨道走完后留出一段停留:

Loading…

无障碍与交互 Accessibility

  • 自动降级为原生横向滚动:在开启 prefers-reduced-motion: reduce 的系统中,组件将不会劫持垂直滚动高度,而是直接降级为标准的 overflow-x: auto 原生滚动条容器,保证完全可访问。
  • 键盘导航兼容:横向轨道内的卡片依然按 DOM 顺序排布,使用 Tab 键可以在各个卡片内的按钮、链接之间正常顺畅聚焦。