组件
滚动叙事序列 Scroll Sequence
将吸顶视口与滚动进度深度绑定,随着用户向下滚动在原地依次平滑切换阶段步骤或故事画面的组件。
基础用法
向下滚动,吸顶视口中的工作流步骤(连接仓库、编译校验、全网部署)依次在原地无缝过渡:
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"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
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 包含的多个分段步骤子节点,随滚动进度逐一激活切换渲染。 |
| stepLength | number | 0.65 | 每个步骤切换所消耗的滚动距离(以当前视口高度为单位,如 0.65 代表 0.65 个视口高度)。 |
| effect | "slide" | "fade" | "blur" | "slide" | 步骤之间的过渡方式。`slide` 与 `blur` 会跟随滚动方向:向下时新步骤自下而上进入,回滚时反向。 |
| container | React.RefObject<HTMLElement | null> | — | 局部滚动容器的 ref 引用(不传时监听整个页面全局滚动)。 |
| showProgress | boolean | true | 是否在视口底部居中展示分段进度条,每一段随当前步骤内的滚动进度连续填充。 |
| onStepChange | (index: number) => void | — | 当前激活的步骤索引发生变化时的回调函数。 |
| viewportClassName | string | — | 应用于吸顶固定视口容器(Sticky Viewport)的 CSS 类名。 |
| stepClassName | string | — | 应用于当前活动步骤内容包装容器的 CSS 类名。 |
| className | string | — | 应用于外层占位高度容器的 CSS 类名。 |
使用场景与设计规范
ScrollSequence 适用于产品分步指南、复杂技术架构拆解、时间线故事叙事:
- 聚焦单点认知:让用户在不迷失页面纵向位置的同时,逐步浏览有序的阶段信息。
- 指示条联动反馈:底部分段进度条连续填充,用户能感知“离下一步还有多远”。
- 交叉过渡:新旧步骤在同一网格单元内同时过渡,不会出现等待上一步完全退场的空白。
场景示例
数字叙事
使用 effect="blur" 逐段讲述增长故事:
Loading…
无障碍与交互 Accessibility
- 静态栅格回退:当检测到
prefers-reduced-motion: reduce时,组件将放弃滚动吸顶逻辑,直接将所有步骤平铺为标准的 CSS Grid 垂直列表,确保所有内容无障碍直达。 - 步骤 ARIA 语义:底部指示条为
role="status"并标注aria-label="Step X of Y",步骤切换时辅助工具可播报当前位置。