wui
组件

滚动叙事序列 Scroll Sequence

将吸顶视口与滚动进度深度绑定,随着用户向下滚动在原地依次平滑切换阶段步骤或故事画面的组件。

第三方依赖 · motion

基础用法

向下滚动,吸顶视口中的工作流步骤(连接仓库、编译校验、全网部署)依次在原地无缝过渡:

Loading…

安装与引入

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

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

import * as React from "react"
import {
  AnimatePresence,
  motion,
  useMotionValueEvent,
  useReducedMotion,
  useScroll,
  useTransform,
  type HTMLMotionProps,
  type MotionValue,
  type Variants,
} from "motion/react"

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

export interface ScrollSequenceProps extends Omit<
  HTMLMotionProps<"section">,
  "children"
> {
  /** Steps displayed one at a time in the pinned viewport. */
  children: React.ReactNode
  /** Scroll distance per transition, in viewport-height units. @default 0.65 */
  stepLength?: number
  /** Transition used between steps. Direction follows the scroll direction. @default "slide" */
  effect?: "slide" | "fade" | "blur"
  /** Scrollable element to observe instead of the page. */
  container?: React.RefObject<HTMLElement | null>
  /** Show a compact step indicator that fills with scroll. @default true */
  showProgress?: boolean
  /** Called when the active step changes. */
  onStepChange?: (index: number) => void
  /** Classes applied to the pinned viewport. */
  viewportClassName?: string
  /** Classes applied to the active step wrapper. */
  stepClassName?: string
}

function getViewportHeight(container?: HTMLElement | null) {
  if (!container) return window.innerHeight
  const style = window.getComputedStyle(container)
  return (
    container.clientHeight -
    parseFloat(style.paddingTop) -
    parseFloat(style.paddingBottom)
  )
}

const stepVariants: Record<
  NonNullable<ScrollSequenceProps["effect"]>,
  Variants
> = {
  slide: {
    enter: (direction: number) => ({ opacity: 0, y: 28 * direction }),
    center: { opacity: 1, y: 0 },
    exit: (direction: number) => ({ opacity: 0, y: -28 * direction }),
  },
  fade: {
    enter: { opacity: 0 },
    center: { opacity: 1 },
    exit: { opacity: 0 },
  },
  blur: {
    enter: (direction: number) => ({
      opacity: 0,
      y: 12 * direction,
      filter: "blur(8px)",
    }),
    center: { opacity: 1, y: 0, filter: "blur(0px)" },
    exit: (direction: number) => ({
      opacity: 0,
      y: -12 * direction,
      filter: "blur(8px)",
    }),
  },
}

interface IndicatorSegmentProps {
  index: number
  count: number
  progress: MotionValue<number>
}

function IndicatorSegment({ index, count, progress }: IndicatorSegmentProps) {
  const fill = useTransform(progress, (latest) =>
    Math.min(Math.max(latest * count - index, 0), 1)
  )

  return (
    <span className="bg-foreground/15 relative h-0.5 w-6 overflow-hidden rounded-full">
      <motion.span
        className="bg-foreground absolute inset-0 origin-left"
        style={{ scaleX: fill }}
      />
    </span>
  )
}

/** Pins a viewport and swaps its child steps according to scroll progress. */
function ScrollSequence({
  children,
  stepLength = 0.65,
  effect = "slide",
  container,
  showProgress = true,
  onStepChange,
  className,
  viewportClassName,
  stepClassName,
  style,
  ...props
}: ScrollSequenceProps) {
  const sectionRef = React.useRef<HTMLElement>(null)
  const steps = React.Children.toArray(children)
  const [[activeStep, direction], setActive] = React.useState<[number, number]>(
    [0, 1]
  )
  const [viewportHeight, setViewportHeight] = React.useState(0)
  const reduceMotion = useReducedMotion()
  const { scrollYProgress } = useScroll({
    target: sectionRef,
    container,
    offset: ["start start", "end end"],
  })

  useMotionValueEvent(scrollYProgress, "change", (latest) => {
    const next = Math.max(
      0,
      Math.min(steps.length - 1, Math.floor(latest * steps.length))
    )
    if (next !== activeStep) {
      setActive([next, next > activeStep ? 1 : -1])
      onStepChange?.(next)
    }
  })

  React.useLayoutEffect(() => {
    const measure = () => {
      setViewportHeight(getViewportHeight(container?.current))
    }
    measure()
    const observer = new ResizeObserver(measure)
    if (container?.current) observer.observe(container.current)
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      window.removeEventListener("resize", measure)
    }
  }, [container])

  if (reduceMotion) {
    return (
      <motion.section
        ref={sectionRef}
        data-slot="scroll-sequence"
        className={className}
        style={style}
        {...props}
      >
        <div className={cn("grid gap-6", viewportClassName)}>{children}</div>
      </motion.section>
    )
  }

  const sectionHeight = viewportHeight
    ? viewportHeight * (1 + Math.max(steps.length - 1, 0) * stepLength)
    : `${(1 + Math.max(steps.length - 1, 0) * stepLength) * 100}vh`
  const activeKey = React.isValidElement(steps[activeStep])
    ? steps[activeStep].key
    : activeStep

  return (
    <motion.section
      ref={sectionRef}
      data-slot="scroll-sequence"
      className={cn("relative", className)}
      style={{ ...style, height: sectionHeight }}
      {...props}
    >
      <div
        data-slot="scroll-sequence-viewport"
        className={cn("sticky top-0 grid overflow-hidden", viewportClassName)}
        style={{ height: viewportHeight || "100vh" }}
      >
        <AnimatePresence initial={false} custom={direction}>
          <motion.div
            key={activeKey}
            data-slot="scroll-sequence-step"
            className={cn("col-start-1 row-start-1 h-full w-full", stepClassName)}
            custom={direction}
            variants={stepVariants[effect]}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
          >
            {steps[activeStep]}
          </motion.div>
        </AnimatePresence>

        {showProgress && steps.length > 1 ? (
          <div
            role="status"
            aria-label={`Step ${activeStep + 1} of ${steps.length}`}
            data-slot="scroll-sequence-progress"
            className="pointer-events-none absolute bottom-5 left-1/2 flex -translate-x-1/2 gap-1.5"
          >
            {steps.map((_, index) => (
              <IndicatorSegment
                key={index}
                index={index}
                count={steps.length}
                progress={scrollYProgress}
              />
            ))}
          </div>
        ) : null}
      </div>
    </motion.section>
  )
}

export { ScrollSequence }

属性 Props

属性类型默认值说明
childrenReact.ReactNode—包含的多个分段步骤子节点,随滚动进度逐一激活切换渲染。
stepLengthnumber0.65每个步骤切换所消耗的滚动距离(以当前视口高度为单位,如 0.65 代表 0.65 个视口高度)。
effect"slide" | "fade" | "blur""slide"步骤之间的过渡方式。`slide` 与 `blur` 会跟随滚动方向:向下时新步骤自下而上进入,回滚时反向。
containerReact.RefObject<HTMLElement | null>—局部滚动容器的 ref 引用(不传时监听整个页面全局滚动)。
showProgressbooleantrue是否在视口底部居中展示分段进度条,每一段随当前步骤内的滚动进度连续填充。
onStepChange(index: number) => void—当前激活的步骤索引发生变化时的回调函数。
viewportClassNamestring—应用于吸顶固定视口容器(Sticky Viewport)的 CSS 类名。
stepClassNamestring—应用于当前活动步骤内容包装容器的 CSS 类名。
classNamestring—应用于外层占位高度容器的 CSS 类名。

使用场景与设计规范

ScrollSequence 适用于产品分步指南、复杂技术架构拆解、时间线故事叙事:

  • 聚焦单点认知:让用户在不迷失页面纵向位置的同时,逐步浏览有序的阶段信息。
  • 指示条联动反馈:底部分段进度条连续填充,用户能感知“离下一步还有多远”。
  • 交叉过渡:新旧步骤在同一网格单元内同时过渡,不会出现等待上一步完全退场的空白。

场景示例

数字叙事

使用 effect="blur" 逐段讲述增长故事:

Loading…

无障碍与交互 Accessibility

  • 静态栅格回退:当检测到 prefers-reduced-motion: reduce 时,组件将放弃滚动吸顶逻辑,直接将所有步骤平铺为标准的 CSS Grid 垂直列表,确保所有内容无障碍直达。
  • 步骤 ARIA 语义:底部指示条为 role="status" 并标注 aria-label="Step X of Y",步骤切换时辅助工具可播报当前位置。