wui
组件

粘性层叠卡片 Sticky Stack

卡片在向下滚动时依次吸顶、层叠堆积并微弱退后缩小的多层视觉递进组件。

第三方依赖 · motion

基础用法

向下滚动,卡片依次吸顶;新卡片到来时,下方卡片微微缩小并被 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
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

属性类型默认值说明
childrenReact.ReactNode—包含的多个 StickyStackItem 卡片子节点。
topnumber24第一张卡片吸顶时距离视口顶部的像素距离。
gapnumber12后续层叠卡片相较于上一张卡片在顶部露出的垂直间距(像素)。
scaleStepnumber0.035每被一张新卡片盖在上方时,底层卡片逐级衰减缩小的比例步进值(如 0.035 代表缩小 3.5%)。
dimnumber0被覆盖卡片上背景色薄雾的最大不透明度(0–1)。越靠底层的卡片越接近该值,`0` 表示关闭。
containerReact.RefObject<HTMLElement | null>—局部滚动容器的 ref 引用(不传时监听整个页面滚动)。
classNamestring—应用于外层容器的额外 CSS 类名。

StickyStackItem

属性类型默认值说明
childrenReact.ReactNode—单张层叠卡片内部的完整图文、徽章与操作按钮内容。
indexnumber—当前卡片在堆栈中的序号。作为直接子元素时无需传递,组件会自动推断索引。
classNamestring—应用于单张卡片主体的 CSS 类名。

使用场景与设计规范

StickyStack 适用于核心功能三大支柱、递进式流程步骤、定价套餐等级对比:

  • 强化叙事节奏:相比一次性列出所有卡片,粘性层叠让用户的注意力一次只聚焦在一张核心内容上,滚动时带来强烈的成就感与进展感。
  • 层级表达:用 dim 让底层卡片退后,比为每张卡片换背景色更克制;卡片之间的露出高度由 gap 控制,8–16px 即可读出层次。
  • 卡片间距:卡片默认带 mb-[18vh] 的滚动间距,可通过 className 覆盖(如 mb-24)。

场景示例

定价方案

窄栏中的方案卡片逐张叠放,适合在移动端依次对比:

Loading…

无障碍与交互 Accessibility

  • 自动降级:当用户开启 prefers-reduced-motion: reduce 时,卡片将锁定 scale 为 1,仅保留标准的 CSS position: sticky 堆叠,完全消除缩放造成的视觉冲击。
  • DOM 结构平铺:卡片在 DOM 树中依然保持自然的先后顺序,屏幕阅读器与读屏软件可以线性无歧义地逐项读取所有卡片文本。