组件
粘性层叠卡片 Sticky Stack
卡片在向下滚动时依次吸顶、层叠堆积并微弱退后缩小的多层视觉递进组件。
基础用法
向下滚动,卡片依次吸顶;新卡片到来时,下方卡片微微缩小并被 dim 薄雾覆盖,层级自然后退:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/sticky-stack安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/sticky-stack.tsx"use client"
import * as React from "react"
import {
motion,
useReducedMotion,
useScroll,
useTransform,
type HTMLMotionProps,
type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
interface StickyStackContextValue {
count: number
gap: number
scaleStep: number
dim: number
top: number
progress: MotionValue<number>
reduceMotion: boolean
}
const StickyStackContext = React.createContext<StickyStackContextValue | null>(
null
)
export interface StickyStackProps extends Omit<
HTMLMotionProps<"div">,
"children"
> {
/** StickyStackItem elements rendered in stacking order. */
children: React.ReactNode
/** Top offset of the first sticky item in pixels. @default 24 */
top?: number
/** Visible vertical offset between stacked items in pixels. @default 12 */
gap?: number
/** Scale removed for every card placed above an item. @default 0.035 */
scaleStep?: number
/** Maximum opacity of the background veil laid over receding cards. `0` disables it. @default 0 */
dim?: number
/** Scrollable element to observe instead of the page. */
container?: React.RefObject<HTMLElement | null>
}
export interface StickyStackItemProps extends Omit<
HTMLMotionProps<"div">,
"children"
> {
/** Card content. */
children: React.ReactNode
/** Position in the stack. Inferred automatically for direct children. */
index?: number
}
interface StickyStackItemInternalProps extends StickyStackItemProps {
stackIndex?: number
}
/** A group of cards that pin, layer, and recede as the next card arrives. */
function StickyStack({
children,
top = 24,
gap = 12,
scaleStep = 0.035,
dim = 0,
container,
className,
...props
}: StickyStackProps) {
const target = React.useRef<HTMLDivElement>(null)
const reduceMotion = Boolean(useReducedMotion())
const items = React.Children.toArray(children)
const { scrollYProgress } = useScroll({
target,
container,
offset: ["start start", "end end"],
})
return (
<StickyStackContext.Provider
value={{
count: items.length,
gap,
scaleStep,
dim,
top,
progress: scrollYProgress,
reduceMotion,
}}
>
<motion.div
ref={target}
data-slot="sticky-stack"
className={cn("relative", className)}
{...props}
>
{items.map((child, stackIndex) =>
React.isValidElement<StickyStackItemInternalProps>(child) &&
child.type === StickyStackItem
? React.cloneElement(child, { stackIndex })
: child
)}
</motion.div>
</StickyStackContext.Provider>
)
}
/** One card inside a StickyStack. */
function StickyStackItem({
children,
index,
stackIndex,
className,
style,
...props
}: StickyStackItemInternalProps) {
const context = React.useContext(StickyStackContext)
if (!context) {
throw new Error("StickyStackItem must be used inside StickyStack")
}
const itemIndex = index ?? stackIndex ?? 0
const count = Math.max(context.count, 1)
const start = Math.min(itemIndex / count, 0.98)
const targetScale = Math.max(
0.75,
1 - (count - itemIndex - 1) * context.scaleStep
)
const scale = useTransform(context.progress, [start, 1], [1, targetScale])
const veilTarget =
(context.dim * (count - itemIndex - 1)) / Math.max(count - 1, 1)
const veil = useTransform(context.progress, [start, 1], [0, veilTarget])
return (
<motion.div
data-slot="sticky-stack-item"
className={cn("sticky mb-[18vh] origin-top last:mb-0", className)}
style={{
...style,
top: context.top + itemIndex * context.gap,
zIndex: itemIndex + 1,
scale: context.reduceMotion ? 1 : scale,
}}
{...props}
>
{children}
{context.dim > 0 && !context.reduceMotion ? (
<motion.div
aria-hidden="true"
data-slot="sticky-stack-item-veil"
className="bg-background pointer-events-none absolute inset-0 z-10 rounded-[inherit]"
style={{ opacity: veil }}
/>
) : null}
</motion.div>
)
}
export { StickyStack, StickyStackItem }
属性 Props
StickyStack
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 包含的多个 StickyStackItem 卡片子节点。 |
| top | number | 24 | 第一张卡片吸顶时距离视口顶部的像素距离。 |
| gap | number | 12 | 后续层叠卡片相较于上一张卡片在顶部露出的垂直间距(像素)。 |
| scaleStep | number | 0.035 | 每被一张新卡片盖在上方时,底层卡片逐级衰减缩小的比例步进值(如 0.035 代表缩小 3.5%)。 |
| dim | number | 0 | 被覆盖卡片上背景色薄雾的最大不透明度(0–1)。越靠底层的卡片越接近该值,`0` 表示关闭。 |
| container | React.RefObject<HTMLElement | null> | — | 局部滚动容器的 ref 引用(不传时监听整个页面滚动)。 |
| className | string | — | 应用于外层容器的额外 CSS 类名。 |
StickyStackItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 单张层叠卡片内部的完整图文、徽章与操作按钮内容。 |
| index | number | — | 当前卡片在堆栈中的序号。作为直接子元素时无需传递,组件会自动推断索引。 |
| className | string | — | 应用于单张卡片主体的 CSS 类名。 |
使用场景与设计规范
StickyStack 适用于核心功能三大支柱、递进式流程步骤、定价套餐等级对比:
- 强化叙事节奏:相比一次性列出所有卡片,粘性层叠让用户的注意力一次只聚焦在一张核心内容上,滚动时带来强烈的成就感与进展感。
- 层级表达:用
dim让底层卡片退后,比为每张卡片换背景色更克制;卡片之间的露出高度由gap控制,8–16px 即可读出层次。 - 卡片间距:卡片默认带
mb-[18vh]的滚动间距,可通过className覆盖(如mb-24)。
场景示例
定价方案
窄栏中的方案卡片逐张叠放,适合在移动端依次对比:
Loading…
无障碍与交互 Accessibility
- 自动降级:当用户开启
prefers-reduced-motion: reduce时,卡片将锁定 scale 为 1,仅保留标准的 CSSposition: sticky堆叠,完全消除缩放造成的视觉冲击。 - DOM 结构平铺:卡片在 DOM 树中依然保持自然的先后顺序,屏幕阅读器与读屏软件可以线性无歧义地逐项读取所有卡片文本。