跳至正文
组件

Card Stack卡片堆叠

细边框卡片的层叠轮播,支持自动轮换、拖拽、键盘切换与减少动态效果。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/card-stack.json
会连带安装 shadcn 组件 button, 本站条目 grid-layout、stat-card。已存在的文件会询问是否覆盖。

用法

CardStackItem 直接作为 CardStack 子级,内容自由组合。CardStackControls 放在同一级,提供上一张、下一张、指示点与暂停按钮。也可以通过 items 传入 React 节点数组,子级里只保留控制器。

components/card-stack-example.tsx
"use client"

import {
  CardStack,
  CardStackItem,
  CardStackControls,
} from "@/components/ui/card-stack/card-stack"

export function CardStackExample() {
  return (
    <CardStack label="精选作品" className="max-w-sm">
      <CardStackItem>
        <h3>个人组件库</h3>
        <p>从重复的界面里提炼原语。</p>
      </CardStackItem>
      <CardStackItem>
        <h3>开发者笔记</h3>
        <p>记录解决问题的过程。</p>
      </CardStackItem>
      <CardStackControls />
    </CardStack>
  )
}

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整模式、轮换间隔、层叠深度、偏移与缩放,并开关暂停;未改动的项显示示例自己的取值。调整时预览画框大小不变。

引言堆叠

大引号、正文与署名组成卡片,内容高度由所有卡片的最大高度撑开,切换时页面不会跳动。示例文案为虚构内容。

正在加载…

拖拽预览

mode="drag" 关闭定时轮换。拖动距离超过 80px 时顶卡飞出,并进入循环序列底部;未超过阈值则回弹。触屏保留纵向页面滚动。

正在加载…

指标卡

卡片内部可直接组合 stat-card 的标题、数值和趋势原语。堆叠本身不依赖统计卡;该 registry 依赖供此示例组合使用。

正在加载…

API

CardStack

属性默认值说明
modeautoauto 定时轮换;drag 手动拖动
interval4000轮换间隔,毫秒,最小 1000
depth3可见层数,限制在卡片数量以内
offset12每层向下偏移的像素,最小 0
scale0.04每层缩小的比例;累计缩小最多 20%
index / defaultIndex— / 0受控索引或非受控初始索引,按卡片数量循环归一化
onIndexChange—接收新的零起始索引;受控时由调用方更新 index
items—React 节点数组,与直接子级卡片择一使用
label卡片堆叠轮播区域的可访问名称
labels中文覆盖前后切换、暂停、继续、卡片编号和轮播角色文案

自动模式在悬停、焦点位于组件内、离开视口或页面隐藏时暂停,恢复后重新计时。暂停按钮可持续暂停轮换。若不放控制器,调用方应提供等价的暂停入口。

CardStackItem

细边框、无阴影的卡片容器;支持原生 div 属性。仅顶卡可交互,其他卡片使用 inert 与 aria-hidden。

CardStackItem 没有自定义 Props;该组件继承原生元素的全部属性。

CardStackControls

按钮统一使用 icon-sm(28px)。指示点是带 aria-pressed 的按钮;自动模式附带暂停控制,零张或单张卡片时前后切换按钮禁用。

CardStackControls 没有自定义 Props;该组件继承原生元素的全部属性。

调整 Context

CardStackTuningContext 接收 CardStackTuning 覆盖项;onReport(report: CardStackReport) 回报覆盖前的实际属性。 stableFrame 固定调整期间的画框,不改变图形比例。

可访问性

  • 根容器可聚焦;左右方向键切换,控制器按钮也支持原生键盘操作。
  • 卡片内的输入框、滑块等保留自己的方向键操作,不拦截内部内容的键盘事件。
  • 自动轮换不持续播报;手动操作或暂停时,当前卡片编号通过礼貌播报区更新。
  • 减少动态效果时取消层叠位移、缩放和飞出,以淡入切换;拖拽仍可完成切换。
  • 自动轮换时建议始终保留 CardStackControls,便于用户主动暂停。

注意事项

  • 调整面板通过 CardStackTuningContext 覆盖子树里的 mode、interval、depth、offset、scale、paused,并用 onReport 回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。

  • 演示使用固定数据,首屏不依赖随机数或当前时间,适用于静态导出。

  • 卡片与控制器需直接放在根容器下;items 支持任意节点。动态列表请使用稳定的 React key。

  • 拖拽时会暂时超出卡片边缘,请按场景在外部预留空间或设置裁切区域。

  • 指标示例的统计格式与涨跌约定沿用 stat-card。

源码

components/ui/card-stack/card-stack.tsx
"use client"

import * as React from "react"
import { useInView } from "motion/react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { CardStackLayer } from "./card-stack-layer"
import { CardStackControls } from "./controls"
import {
  CardStackContext,
  useCardStackReducedMotion,
  DEFAULT_LABELS,
  type CardStackMode,
  type CardStackLabels,
} from "./context"

/** 文档调整覆盖项;不提供 Provider 时沿用组件属性。 */
type CardStackReport = {
  mode: CardStackMode
  interval: number
  depth: number
  offset: number
  scale: number
  paused: boolean
}
type CardStackTuning = Partial<CardStackReport> & {
  /** 固定调整期间的画框。@default false */
  stableFrame?: boolean
  /** 回报覆盖前的示例取值。 */
  onReport?: (report: CardStackReport) => void
}
const CardStackTuningContext = React.createContext<CardStackTuning>({})

type CardStackProps = React.ComponentProps<"div"> & {
  /** 也可用直接子级 CardStackItem;两者择一。 */
  items?: React.ReactNode[]
  mode?: CardStackMode
  interval?: number
  depth?: number
  /** 每层向下偏移的像素。 */
  offset?: number
  /** 每层缩小的比例,例如 0.04。 */
  scale?: number
  index?: number
  defaultIndex?: number
  onIndexChange?: (index: number) => void
  label?: string
  labels?: Partial<CardStackLabels>
}

function CardStack({
  items,
  children,
  mode: modeProp = "auto",
  interval: intervalProp = 4000,
  depth: depthProp = 3,
  offset: offsetProp = 12,
  scale: scaleProp = 0.04,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  label = "卡片堆叠",
  labels,
  className,
  style,
  onKeyDown,
  onMouseEnter,
  onMouseLeave,
  onFocusCapture,
  onBlurCapture,
  ...props
}: CardStackProps) {
  const tuning = React.useContext(CardStackTuningContext)
  const { onReport } = tuning
  const mode = tuning.mode ?? modeProp
  const interval = tuning.interval ?? intervalProp
  const depth = tuning.depth ?? depthProp
  const offset = tuning.offset ?? offsetProp
  const scale = tuning.scale ?? scaleProp

  const ref = React.useRef<HTMLDivElement>(null)
  const inView = useInView(ref, { amount: 0.2 })
  const reduced = useCardStackReducedMotion()
  const [hovered, setHovered] = React.useState(false)
  const [focused, setFocused] = React.useState(false)
  const [pausedState, setPaused] = React.useState(false)
  const paused = tuning.paused ?? pausedState
  React.useEffect(() => {
    onReport?.({
      mode: modeProp,
      interval: intervalProp,
      depth: depthProp,
      offset: offsetProp,
      scale: scaleProp,
      paused: pausedState,
    })
  }, [
    onReport,
    modeProp,
    intervalProp,
    depthProp,
    offsetProp,
    scaleProp,
    pausedState,
  ])

  const [hidden, setHidden] = React.useState(false)
  const [rawIndex, setIndex] = useControllableState({
    prop: indexProp,
    defaultProp: defaultIndex,
    onChange: onIndexChange,
  })
  const parts = React.Children.toArray(children)
  const controls = parts.filter(
    (child) => React.isValidElement(child) && child.type === CardStackControls,
  )
  const cards =
    items ??
    parts.filter(
      (child) => React.isValidElement(child) && child.type === CardStackItem,
    )
  const count = cards.length
  const index = count ? ((Math.trunc(rawIndex) % count) + count) % count : 0
  // 调整层数与偏移只移动图形,底部预留空间仍取示例自己的值。
  const framePadding =
    (Math.max(1, Math.min(count || 1, Math.floor(depthProp))) - 1) *
    Math.max(0, offsetProp)
  const safeDepth = Math.max(1, Math.min(count || 1, Math.floor(depth)))
  const safeOffset = Math.max(0, offset)
  const safeScale = Math.max(0, Math.min(scale, 0.2 / safeDepth))
  const text = { ...DEFAULT_LABELS, ...labels }
  const go = React.useCallback(
    (next: number) => {
      if (count > 1) setIndex(((next % count) + count) % count)
    },
    [count, setIndex],
  )

  React.useEffect(() => {
    function visibility() {
      setHidden(document.hidden)
    }
    visibility()
    document.addEventListener("visibilitychange", visibility)
    return () => document.removeEventListener("visibilitychange", visibility)
  }, [])

  React.useEffect(() => {
    if (
      mode !== "auto" ||
      count < 2 ||
      paused ||
      hovered ||
      focused ||
      hidden ||
      !inView
    )
      return
    const timer = window.setInterval(
      () => go(index + 1),
      Math.max(1000, interval),
    )
    return () => window.clearInterval(timer)
  }, [
    mode,
    count,
    paused,
    hovered,
    focused,
    hidden,
    inView,
    interval,
    index,
    go,
  ])

  return (
    <CardStackContext.Provider
      value={{ index, count, mode, paused, labels: text, setPaused, go }}
    >
      <div
        {...props}
        ref={ref}
        data-slot="card-stack"
        data-mode={mode}
        data-paused={paused}
        data-interval={interval}
        data-depth={safeDepth}
        data-offset={safeOffset}
        data-scale={safeScale}
        role="region"
        aria-roledescription={text.carousel}
        aria-label={label}
        tabIndex={0}
        className={cn(
          "w-full min-w-0 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
          className,
        )}
        style={style}
        onMouseEnter={(event) => {
          onMouseEnter?.(event)
          setHovered(true)
        }}
        onMouseLeave={(event) => {
          onMouseLeave?.(event)
          setHovered(false)
        }}
        onFocusCapture={(event) => {
          onFocusCapture?.(event)
          setFocused(true)
        }}
        onBlurCapture={(event) => {
          onBlurCapture?.(event)
          if (!event.currentTarget.contains(event.relatedTarget))
            setFocused(false)
        }}
        onKeyDown={(event) => {
          onKeyDown?.(event)
          if (
            event.defaultPrevented ||
            (event.target !== event.currentTarget &&
              !(event.target as HTMLElement).closest(
                '[data-slot="card-stack-controls"]',
              ))
          )
            return
          if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
            event.preventDefault()
            go(index + (event.key === "ArrowRight" ? 1 : -1))
          }
        }}
      >
        <div
          data-slot="card-stack-viewport"
          className="grid min-w-0"
          style={{
            paddingBottom: tuning.stableFrame
              ? framePadding
              : (safeDepth - 1) * safeOffset,
          }}
        >
          {cards.map((card, cardIndex) => {
            const level = (cardIndex - index + count) % count
            return (
              <CardStackLayer
                key={
                  React.isValidElement(card)
                    ? (card.key ?? cardIndex)
                    : cardIndex
                }
                level={level}
                visible={level < safeDepth}
                depth={safeDepth}
                offset={safeOffset}
                scale={safeScale}
                reduced={reduced}
                draggable={mode === "drag" && count > 1}
                roleDescription={text.slide}
                label={`${text.card} ${cardIndex + 1} / ${count}`}
                advance={() => go(index + 1)}
              >
                {card}
              </CardStackLayer>
            )
          })}
        </div>
        <span
          data-slot="card-stack-announcement"
          className="sr-only"
          aria-live={mode === "drag" || paused || focused ? "polite" : "off"}
          aria-atomic="true"
        >
          {count ? `${text.card} ${index + 1} / ${count}` : null}
        </span>
        {controls}
      </div>
    </CardStackContext.Provider>
  )
}

function CardStackItem({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-stack-item"
      className={cn(
        "h-full min-w-0 rounded-lg border border-edge bg-card p-6 break-words text-card-foreground",
        className,
      )}
      {...props}
    />
  )
}

export { CardStackTuningContext, CardStack, CardStackItem, CardStackControls }
export type {
  CardStackTuning,
  CardStackReport,
  CardStackProps,
  CardStackMode,
  CardStackLabels,
}
components/ui/card-stack/card-stack-layer.tsx
"use client"

import * as React from "react"
import { animate, motion, useMotionValue, useTransform } from "motion/react"

function CardStackLayer({
  children,
  level,
  visible,
  depth,
  offset,
  scale,
  reduced,
  draggable,
  label,
  roleDescription,
  advance,
}: {
  children: React.ReactNode
  level: number
  visible: boolean
  depth: number
  offset: number
  scale: number
  reduced: boolean
  draggable: boolean
  label: string
  roleDescription: string
  advance: () => void
}) {
  const x = useMotionValue(0)
  const rotate = useTransform(x, [-250, 250], reduced ? [0, 0] : [-12, 12])
  const busy = React.useRef(false)
  const top = level === 0
  const position = Math.min(level, depth - 1)
  return (
    <motion.div
      data-slot="card-stack-layer"
      data-active={top}
      data-draggable={draggable}
      role="group"
      aria-roledescription={roleDescription}
      aria-label={label}
      aria-hidden={!top}
      inert={!top}
      className="col-start-1 row-start-1 min-w-0 origin-bottom data-[active=false]:pointer-events-none data-[active=true]:pointer-events-auto data-[draggable=true]:select-none"
      style={{
        x,
        rotate,
        zIndex: top ? depth + 1 : depth - position,
        touchAction: draggable ? "pan-y" : undefined,
      }}
      initial={false}
      animate={{
        y: reduced ? 0 : position * offset,
        scale: reduced ? 1 : 1 - position * scale,
        opacity: visible && (!reduced || top) ? 1 : 0,
      }}
      transition={{ duration: 0.25, ease: "easeOut" }}
      drag={draggable && top ? "x" : false}
      dragConstraints={{ left: 0, right: 0 }}
      dragElastic={0.8}
      dragMomentum={false}
      onDragEnd={async (_, info) => {
        if (busy.current) return
        if (Math.abs(info.offset.x) < 80) {
          animate(
            x,
            0,
            reduced
              ? { duration: 0 }
              : { type: "spring", stiffness: 400, damping: 30 },
          )
          return
        }
        busy.current = true
        if (!reduced)
          await animate(
            x,
            Math.sign(info.offset.x) * Math.max(600, window.innerWidth),
            { duration: 0.2 },
          )
        advance()
        x.set(0)
        busy.current = false
      }}
    >
      {children}
    </motion.div>
  )
}

export { CardStackLayer }
components/ui/card-stack/context.ts
"use client"

import * as React from "react"

type CardStackMode = "auto" | "drag"
type CardStackLabels = {
  previous: string
  next: string
  pause: string
  play: string
  card: string
  carousel: string
  slide: string
}
const DEFAULT_LABELS: CardStackLabels = {
  carousel: "轮播",
  slide: "幻灯片",
  previous: "上一张",
  next: "下一张",
  pause: "暂停轮换",
  play: "继续轮换",
  card: "卡片",
}
const CardStackContext = React.createContext<{
  index: number
  count: number
  mode: CardStackMode
  paused: boolean
  labels: CardStackLabels
  setPaused: React.Dispatch<React.SetStateAction<boolean>>
  go: (index: number) => void
} | null>(null)

function useCardStack() {
  const context = React.useContext(CardStackContext)
  if (!context) throw new Error("CardStackControls 必须放在 CardStack 内")
  return context
}

// Motion 的偏好 hook 只读取初始值;这里订阅变更,切换系统设置后立即生效。
function subscribeReducedMotion(callback: () => void) {
  const media = window.matchMedia("(prefers-reduced-motion: reduce)")
  media.addEventListener("change", callback)
  return () => media.removeEventListener("change", callback)
}
function reducedMotionSnapshot() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
function serverReducedMotionSnapshot() {
  return true
}
function useCardStackReducedMotion() {
  return React.useSyncExternalStore(
    subscribeReducedMotion,
    reducedMotionSnapshot,
    serverReducedMotionSnapshot,
  )
}

export {
  CardStackContext,
  DEFAULT_LABELS,
  useCardStack,
  useCardStackReducedMotion,
}
export type { CardStackMode, CardStackLabels }
components/ui/card-stack/controls.tsx
"use client"

import * as React from "react"
import { ArrowLeft, ArrowRight, Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useCardStack } from "./context"

function CardStackControls({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const { index, count, mode, paused, setPaused, labels, go } = useCardStack()
  return (
    <div
      data-slot="card-stack-controls"
      className={cn(
        "mt-5 flex flex-wrap items-center justify-center gap-1",
        className,
      )}
      {...props}
    >
      <Button
        size="icon-sm"
        variant="ghost"
        aria-label={labels.previous}
        disabled={count < 2}
        onClick={() => go(index - 1)}
      >
        <ArrowLeft aria-hidden="true" />
      </Button>
      {Array.from({ length: count }, (_, item) => (
        <Button
          key={item}
          size="icon-sm"
          variant="ghost"
          aria-label={`${labels.card} ${item + 1}`}
          aria-pressed={index === item}
          onClick={() => go(item)}
        >
          <span
            data-slot="card-stack-indicator"
            aria-hidden="true"
            className={cn(
              "size-1.5 rounded-full bg-muted-foreground/30",
              item === index && "bg-primary",
            )}
          />
        </Button>
      ))}
      <Button
        size="icon-sm"
        variant="ghost"
        aria-label={labels.next}
        disabled={count < 2}
        onClick={() => go(index + 1)}
      >
        <ArrowRight aria-hidden="true" />
      </Button>
      {mode === "auto" ? (
        <Button
          size="icon-sm"
          variant="ghost"
          aria-label={paused ? labels.play : labels.pause}
          aria-pressed={paused}
          onClick={() => setPaused((value) => !value)}
        >
          {paused ? <Play aria-hidden="true" /> : <Pause aria-hidden="true" />}
        </Button>
      ) : null}
    </div>
  )
}

export { CardStackControls }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }