{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "title": "卡片堆叠",
  "description": "细边框卡片的层叠轮播，支持自动轮换、拖拽、键盘切换与减少动态效果。",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ui/card-stack/card-stack.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useInView } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { CardStackLayer } from \"./card-stack-layer\"\nimport { CardStackControls } from \"./controls\"\nimport {\n  CardStackContext,\n  useCardStackReducedMotion,\n  DEFAULT_LABELS,\n  type CardStackMode,\n  type CardStackLabels,\n} from \"./context\"\n\n/** 文档调整覆盖项；不提供 Provider 时沿用组件属性。 */\ntype CardStackReport = {\n  mode: CardStackMode\n  interval: number\n  depth: number\n  offset: number\n  scale: number\n  paused: boolean\n}\ntype CardStackTuning = Partial<CardStackReport> & {\n  /** 固定调整期间的画框。@default false */\n  stableFrame?: boolean\n  /** 回报覆盖前的示例取值。 */\n  onReport?: (report: CardStackReport) => void\n}\nconst CardStackTuningContext = React.createContext<CardStackTuning>({})\n\ntype CardStackProps = React.ComponentProps<\"div\"> & {\n  /** 也可用直接子级 CardStackItem；两者择一。 */\n  items?: React.ReactNode[]\n  mode?: CardStackMode\n  interval?: number\n  depth?: number\n  /** 每层向下偏移的像素。 */\n  offset?: number\n  /** 每层缩小的比例，例如 0.04。 */\n  scale?: number\n  index?: number\n  defaultIndex?: number\n  onIndexChange?: (index: number) => void\n  label?: string\n  labels?: Partial<CardStackLabels>\n}\n\nfunction CardStack({\n  items,\n  children,\n  mode: modeProp = \"auto\",\n  interval: intervalProp = 4000,\n  depth: depthProp = 3,\n  offset: offsetProp = 12,\n  scale: scaleProp = 0.04,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  label = \"卡片堆叠\",\n  labels,\n  className,\n  style,\n  onKeyDown,\n  onMouseEnter,\n  onMouseLeave,\n  onFocusCapture,\n  onBlurCapture,\n  ...props\n}: CardStackProps) {\n  const tuning = React.useContext(CardStackTuningContext)\n  const { onReport } = tuning\n  const mode = tuning.mode ?? modeProp\n  const interval = tuning.interval ?? intervalProp\n  const depth = tuning.depth ?? depthProp\n  const offset = tuning.offset ?? offsetProp\n  const scale = tuning.scale ?? scaleProp\n\n  const ref = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { amount: 0.2 })\n  const reduced = useCardStackReducedMotion()\n  const [hovered, setHovered] = React.useState(false)\n  const [focused, setFocused] = React.useState(false)\n  const [pausedState, setPaused] = React.useState(false)\n  const paused = tuning.paused ?? pausedState\n  React.useEffect(() => {\n    onReport?.({\n      mode: modeProp,\n      interval: intervalProp,\n      depth: depthProp,\n      offset: offsetProp,\n      scale: scaleProp,\n      paused: pausedState,\n    })\n  }, [\n    onReport,\n    modeProp,\n    intervalProp,\n    depthProp,\n    offsetProp,\n    scaleProp,\n    pausedState,\n  ])\n\n  const [hidden, setHidden] = React.useState(false)\n  const [rawIndex, setIndex] = useControllableState({\n    prop: indexProp,\n    defaultProp: defaultIndex,\n    onChange: onIndexChange,\n  })\n  const parts = React.Children.toArray(children)\n  const controls = parts.filter(\n    (child) => React.isValidElement(child) && child.type === CardStackControls,\n  )\n  const cards =\n    items ??\n    parts.filter(\n      (child) => React.isValidElement(child) && child.type === CardStackItem,\n    )\n  const count = cards.length\n  const index = count ? ((Math.trunc(rawIndex) % count) + count) % count : 0\n  // 调整层数与偏移只移动图形，底部预留空间仍取示例自己的值。\n  const framePadding =\n    (Math.max(1, Math.min(count || 1, Math.floor(depthProp))) - 1) *\n    Math.max(0, offsetProp)\n  const safeDepth = Math.max(1, Math.min(count || 1, Math.floor(depth)))\n  const safeOffset = Math.max(0, offset)\n  const safeScale = Math.max(0, Math.min(scale, 0.2 / safeDepth))\n  const text = { ...DEFAULT_LABELS, ...labels }\n  const go = React.useCallback(\n    (next: number) => {\n      if (count > 1) setIndex(((next % count) + count) % count)\n    },\n    [count, setIndex],\n  )\n\n  React.useEffect(() => {\n    function visibility() {\n      setHidden(document.hidden)\n    }\n    visibility()\n    document.addEventListener(\"visibilitychange\", visibility)\n    return () => document.removeEventListener(\"visibilitychange\", visibility)\n  }, [])\n\n  React.useEffect(() => {\n    if (\n      mode !== \"auto\" ||\n      count < 2 ||\n      paused ||\n      hovered ||\n      focused ||\n      hidden ||\n      !inView\n    )\n      return\n    const timer = window.setInterval(\n      () => go(index + 1),\n      Math.max(1000, interval),\n    )\n    return () => window.clearInterval(timer)\n  }, [\n    mode,\n    count,\n    paused,\n    hovered,\n    focused,\n    hidden,\n    inView,\n    interval,\n    index,\n    go,\n  ])\n\n  return (\n    <CardStackContext.Provider\n      value={{ index, count, mode, paused, labels: text, setPaused, go }}\n    >\n      <div\n        {...props}\n        ref={ref}\n        data-slot=\"card-stack\"\n        data-mode={mode}\n        data-paused={paused}\n        data-interval={interval}\n        data-depth={safeDepth}\n        data-offset={safeOffset}\n        data-scale={safeScale}\n        role=\"region\"\n        aria-roledescription={text.carousel}\n        aria-label={label}\n        tabIndex={0}\n        className={cn(\n          \"w-full min-w-0 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50\",\n          className,\n        )}\n        style={style}\n        onMouseEnter={(event) => {\n          onMouseEnter?.(event)\n          setHovered(true)\n        }}\n        onMouseLeave={(event) => {\n          onMouseLeave?.(event)\n          setHovered(false)\n        }}\n        onFocusCapture={(event) => {\n          onFocusCapture?.(event)\n          setFocused(true)\n        }}\n        onBlurCapture={(event) => {\n          onBlurCapture?.(event)\n          if (!event.currentTarget.contains(event.relatedTarget))\n            setFocused(false)\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (\n            event.defaultPrevented ||\n            (event.target !== event.currentTarget &&\n              !(event.target as HTMLElement).closest(\n                '[data-slot=\"card-stack-controls\"]',\n              ))\n          )\n            return\n          if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n            event.preventDefault()\n            go(index + (event.key === \"ArrowRight\" ? 1 : -1))\n          }\n        }}\n      >\n        <div\n          data-slot=\"card-stack-viewport\"\n          className=\"grid min-w-0\"\n          style={{\n            paddingBottom: tuning.stableFrame\n              ? framePadding\n              : (safeDepth - 1) * safeOffset,\n          }}\n        >\n          {cards.map((card, cardIndex) => {\n            const level = (cardIndex - index + count) % count\n            return (\n              <CardStackLayer\n                key={\n                  React.isValidElement(card)\n                    ? (card.key ?? cardIndex)\n                    : cardIndex\n                }\n                level={level}\n                visible={level < safeDepth}\n                depth={safeDepth}\n                offset={safeOffset}\n                scale={safeScale}\n                reduced={reduced}\n                draggable={mode === \"drag\" && count > 1}\n                roleDescription={text.slide}\n                label={`${text.card} ${cardIndex + 1} / ${count}`}\n                advance={() => go(index + 1)}\n              >\n                {card}\n              </CardStackLayer>\n            )\n          })}\n        </div>\n        <span\n          data-slot=\"card-stack-announcement\"\n          className=\"sr-only\"\n          aria-live={mode === \"drag\" || paused || focused ? \"polite\" : \"off\"}\n          aria-atomic=\"true\"\n        >\n          {count ? `${text.card} ${index + 1} / ${count}` : null}\n        </span>\n        {controls}\n      </div>\n    </CardStackContext.Provider>\n  )\n}\n\nfunction CardStackItem({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-stack-item\"\n      className={cn(\n        \"h-full min-w-0 rounded-lg border border-edge bg-card p-6 break-words text-card-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { CardStackTuningContext, CardStack, CardStackItem, CardStackControls }\nexport type {\n  CardStackTuning,\n  CardStackReport,\n  CardStackProps,\n  CardStackMode,\n  CardStackLabels,\n}\n",
      "type": "registry:ui",
      "target": "@ui/card-stack/card-stack.tsx"
    },
    {
      "path": "src/registry/ui/card-stack/card-stack-layer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\"\n\nfunction CardStackLayer({\n  children,\n  level,\n  visible,\n  depth,\n  offset,\n  scale,\n  reduced,\n  draggable,\n  label,\n  roleDescription,\n  advance,\n}: {\n  children: React.ReactNode\n  level: number\n  visible: boolean\n  depth: number\n  offset: number\n  scale: number\n  reduced: boolean\n  draggable: boolean\n  label: string\n  roleDescription: string\n  advance: () => void\n}) {\n  const x = useMotionValue(0)\n  const rotate = useTransform(x, [-250, 250], reduced ? [0, 0] : [-12, 12])\n  const busy = React.useRef(false)\n  const top = level === 0\n  const position = Math.min(level, depth - 1)\n  return (\n    <motion.div\n      data-slot=\"card-stack-layer\"\n      data-active={top}\n      data-draggable={draggable}\n      role=\"group\"\n      aria-roledescription={roleDescription}\n      aria-label={label}\n      aria-hidden={!top}\n      inert={!top}\n      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\"\n      style={{\n        x,\n        rotate,\n        zIndex: top ? depth + 1 : depth - position,\n        touchAction: draggable ? \"pan-y\" : undefined,\n      }}\n      initial={false}\n      animate={{\n        y: reduced ? 0 : position * offset,\n        scale: reduced ? 1 : 1 - position * scale,\n        opacity: visible && (!reduced || top) ? 1 : 0,\n      }}\n      transition={{ duration: 0.25, ease: \"easeOut\" }}\n      drag={draggable && top ? \"x\" : false}\n      dragConstraints={{ left: 0, right: 0 }}\n      dragElastic={0.8}\n      dragMomentum={false}\n      onDragEnd={async (_, info) => {\n        if (busy.current) return\n        if (Math.abs(info.offset.x) < 80) {\n          animate(\n            x,\n            0,\n            reduced\n              ? { duration: 0 }\n              : { type: \"spring\", stiffness: 400, damping: 30 },\n          )\n          return\n        }\n        busy.current = true\n        if (!reduced)\n          await animate(\n            x,\n            Math.sign(info.offset.x) * Math.max(600, window.innerWidth),\n            { duration: 0.2 },\n          )\n        advance()\n        x.set(0)\n        busy.current = false\n      }}\n    >\n      {children}\n    </motion.div>\n  )\n}\n\nexport { CardStackLayer }\n",
      "type": "registry:ui",
      "target": "@ui/card-stack/card-stack-layer.tsx"
    },
    {
      "path": "src/registry/ui/card-stack/context.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype CardStackMode = \"auto\" | \"drag\"\ntype CardStackLabels = {\n  previous: string\n  next: string\n  pause: string\n  play: string\n  card: string\n  carousel: string\n  slide: string\n}\nconst DEFAULT_LABELS: CardStackLabels = {\n  carousel: \"轮播\",\n  slide: \"幻灯片\",\n  previous: \"上一张\",\n  next: \"下一张\",\n  pause: \"暂停轮换\",\n  play: \"继续轮换\",\n  card: \"卡片\",\n}\nconst CardStackContext = React.createContext<{\n  index: number\n  count: number\n  mode: CardStackMode\n  paused: boolean\n  labels: CardStackLabels\n  setPaused: React.Dispatch<React.SetStateAction<boolean>>\n  go: (index: number) => void\n} | null>(null)\n\nfunction useCardStack() {\n  const context = React.useContext(CardStackContext)\n  if (!context) throw new Error(\"CardStackControls 必须放在 CardStack 内\")\n  return context\n}\n\n// Motion 的偏好 hook 只读取初始值；这里订阅变更，切换系统设置后立即生效。\nfunction subscribeReducedMotion(callback: () => void) {\n  const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n  media.addEventListener(\"change\", callback)\n  return () => media.removeEventListener(\"change\", callback)\n}\nfunction reducedMotionSnapshot() {\n  return window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n}\nfunction serverReducedMotionSnapshot() {\n  return true\n}\nfunction useCardStackReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    reducedMotionSnapshot,\n    serverReducedMotionSnapshot,\n  )\n}\n\nexport {\n  CardStackContext,\n  DEFAULT_LABELS,\n  useCardStack,\n  useCardStackReducedMotion,\n}\nexport type { CardStackMode, CardStackLabels }\n",
      "type": "registry:ui",
      "target": "@ui/card-stack/context.ts"
    },
    {
      "path": "src/registry/ui/card-stack/controls.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, Pause, Play } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\nimport { useCardStack } from \"./context\"\n\nfunction CardStackControls({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  const { index, count, mode, paused, setPaused, labels, go } = useCardStack()\n  return (\n    <div\n      data-slot=\"card-stack-controls\"\n      className={cn(\n        \"mt-5 flex flex-wrap items-center justify-center gap-1\",\n        className,\n      )}\n      {...props}\n    >\n      <Button\n        size=\"icon-sm\"\n        variant=\"ghost\"\n        aria-label={labels.previous}\n        disabled={count < 2}\n        onClick={() => go(index - 1)}\n      >\n        <ArrowLeft aria-hidden=\"true\" />\n      </Button>\n      {Array.from({ length: count }, (_, item) => (\n        <Button\n          key={item}\n          size=\"icon-sm\"\n          variant=\"ghost\"\n          aria-label={`${labels.card} ${item + 1}`}\n          aria-pressed={index === item}\n          onClick={() => go(item)}\n        >\n          <span\n            data-slot=\"card-stack-indicator\"\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-1.5 rounded-full bg-muted-foreground/30\",\n              item === index && \"bg-primary\",\n            )}\n          />\n        </Button>\n      ))}\n      <Button\n        size=\"icon-sm\"\n        variant=\"ghost\"\n        aria-label={labels.next}\n        disabled={count < 2}\n        onClick={() => go(index + 1)}\n      >\n        <ArrowRight aria-hidden=\"true\" />\n      </Button>\n      {mode === \"auto\" ? (\n        <Button\n          size=\"icon-sm\"\n          variant=\"ghost\"\n          aria-label={paused ? labels.play : labels.pause}\n          aria-pressed={paused}\n          onClick={() => setPaused((value) => !value)}\n        >\n          {paused ? <Play aria-hidden=\"true\" /> : <Pause aria-hidden=\"true\" />}\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\nexport { CardStackControls }\n",
      "type": "registry:ui",
      "target": "@ui/card-stack/controls.tsx"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
