import type { ReactNode } from "react"
import { ArrowUpRight } from "lucide-react"
import {
CardStack,
CardStackControls,
CardStackItem,
} from "@/components/ui/card-stack/card-stack"
import {
flowToneStyle,
type FlowToneName,
} from "@/components/ui/flow-diagram/tone"
// 每张卡有自己的色调与封面图形;卡片底部一条同色细带,叠在后面时露出来,层次一眼可见
const LINES =
"bg-[linear-gradient(to_right,var(--flow-tone)_1px,transparent_1px),linear-gradient(to_bottom,var(--flow-tone)_1px,transparent_1px)] bg-[size:14px_14px] opacity-[0.16]"
function Blocks() {
return (
<div className="relative size-16" aria-hidden="true">
<span className="absolute bottom-0 left-0 size-9 rounded-md border border-(--flow-tone)/40 bg-(--flow-tone)/15" />
<span className="absolute right-0 bottom-0 size-9 rounded-md border border-(--flow-tone)/40 bg-(--flow-tone)/30" />
<span className="absolute top-0 left-3.5 size-9 rounded-md border border-(--flow-tone) bg-(--flow-tone)" />
</div>
)
}
function TextLines() {
return (
<div className="flex w-24 flex-col gap-2" aria-hidden="true">
<span className="h-1.5 w-full rounded-full bg-(--flow-tone)" />
<span className="h-1.5 w-4/5 rounded-full bg-(--flow-tone)/45" />
<span className="h-1.5 w-full rounded-full bg-(--flow-tone)/45" />
<span className="h-1.5 w-3/5 rounded-full bg-(--flow-tone)/25" />
</div>
)
}
function Bars() {
return (
<div className="flex h-14 items-end gap-1.5" aria-hidden="true">
{[40, 65, 50, 85, 60, 100].map((height, index) => (
<span
key={index}
className="w-3 rounded-t-[3px] bg-(--flow-tone)"
style={{ height: `${height}%`, opacity: index === 5 ? 1 : 0.4 }}
/>
))}
</div>
)
}
function Sparkline() {
return (
<svg
viewBox="0 0 96 48"
className="h-14 w-28 text-(--flow-tone)"
aria-hidden="true"
>
<path
d="M2 38 C14 36 18 20 30 24 S46 40 58 22 S78 6 94 10 L94 48 L2 48 Z"
fill="currentColor"
fillOpacity="0.15"
/>
<path
d="M2 38 C14 36 18 20 30 24 S46 40 58 22 S78 6 94 10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
<circle cx="94" cy="10" r="3.5" fill="currentColor" />
</svg>
)
}
const projects: {
tone: FlowToneName
name: string
title: string
description: string
tags: string[]
art: ReactNode
}[] = [
{
tone: "blue",
name: "个人组件库",
title: "把界面,写成积木",
description: "从重复的界面里,提炼可以自由组合的原语。",
tags: ["React", "Tailwind"],
art: <Blocks />,
},
{
tone: "violet",
name: "开发者笔记",
title: "让想法有处安放",
description: "记录解决问题的过程,也留下新的疑问。",
tags: ["Next.js", "MDX"],
art: <TextLines />,
},
{
tone: "amber",
name: "构建面板",
title: "把耗时,看得见",
description: "每次构建的体积与耗时,一眼看出变化。",
tags: ["TypeScript", "图表"],
art: <Bars />,
},
{
tone: "green",
name: "站点数据",
title: "访客,慢慢变多",
description: "只在构建时取数,静态导出也有实时感。",
tags: ["静态导出", "OSS"],
art: <Sparkline />,
},
]
export default function CardStackDemo() {
return (
<div className="w-full max-w-xs">
<CardStack
mode="drag"
label="精选项目"
offset={14}
scale={0.05}
depth={3}
>
{projects.map((project) => (
<CardStackItem
key={project.name}
className="flex flex-col overflow-hidden p-0 shadow-sm"
style={flowToneStyle(project.tone)}
>
<div className="relative flex h-24 items-center justify-center border-b border-edge bg-(--flow-tone)/8">
<div aria-hidden="true" className={`absolute inset-0 ${LINES}`} />
<div className="relative mt-3">{project.art}</div>
</div>
<div className="flex flex-1 flex-col p-4">
<p className="mb-1 text-xs text-muted-foreground">
{project.name}
</p>
<h3 className="text-lg font-medium tracking-tight">
{project.title}
</h3>
<p className="mt-2 text-[13px] leading-5 text-muted-foreground">
{project.description}
</p>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span
key={tag}
className="rounded-md border border-edge bg-background px-2 py-0.5 font-mono text-[11px]"
>
{tag}
</span>
))}
</div>
<ArrowUpRight
className="size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</div>
</div>
<div aria-hidden="true" className="h-1.5 bg-(--flow-tone)" />
</CardStackItem>
))}
<CardStackControls className="mt-3" />
</CardStack>
<p className="mt-1 text-center font-mono text-[11px] text-muted-foreground">
← 拖动卡片切换 →
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/card-stack.json用法
CardStackItem 直接作为 CardStack 子级,内容自由组合。CardStackControls 放在同一级,提供上一张、下一张、指示点与暂停按钮。也可以通过 items 传入 React 节点数组,子级里只保留控制器。
"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>
)
}示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整模式、轮换间隔、层叠深度、偏移与缩放,并开关暂停;未改动的项显示示例自己的取值。调整时预览画框大小不变。
引言堆叠
大引号、正文与署名组成卡片,内容高度由所有卡片的最大高度撑开,切换时页面不会跳动。示例文案为虚构内容。
"use client"
import {
CardStack,
CardStackControls,
CardStackItem,
} from "@/components/ui/card-stack/card-stack"
const QUOTES = [
{
quote: "好的工具,让人把注意力留给真正想做的事。",
author: "林川",
role: "独立开发者",
},
{
quote: "少一点配置,多一点可以直接开始的空间。",
author: "许言",
role: "产品设计师",
},
{
quote: "每一个看似简单的交互,都值得认真打磨。",
author: "周宁",
role: "前端工程师",
},
]
export default function CardStackQuotesDemo() {
return (
<div className="w-full max-w-sm px-2 py-4">
<CardStack label="开发者的话" interval={6000}>
{QUOTES.map(({ quote, author, role }) => (
<CardStackItem key={author}>
<blockquote>
<span
aria-hidden="true"
className="block h-14 font-serif text-7xl leading-none text-primary/40"
>
“
</span>
<p className="min-h-24 text-lg leading-8 tracking-tight text-balance">
{quote}
</p>
<footer className="mt-6 border-t border-edge pt-4 text-sm">
{author}
<span className="ml-3 text-xs text-muted-foreground">
{role}
</span>
</footer>
</blockquote>
</CardStackItem>
))}
<CardStackControls />
</CardStack>
</div>
)
}
拖拽预览
mode="drag" 关闭定时轮换。拖动距离超过 80px 时顶卡飞出,并进入循环序列底部;未超过阈值则回弹。触屏保留纵向页面滚动。
"use client"
import {
CardStack,
CardStackControls,
CardStackItem,
} from "@/components/ui/card-stack/card-stack"
export default function CardStackDragDemo() {
return (
<div className="w-full max-w-sm px-2 py-4">
<CardStack mode="drag" label="拖拽组件预览" offset={14}>
<CardStackItem>
<div
className="grid h-32 grid-cols-[1fr_2fr] gap-px overflow-hidden rounded-md border border-edge bg-edge"
aria-hidden="true"
>
<div className="bg-muted" />
<div className="grid grid-rows-2 gap-px bg-edge">
<div className="bg-primary/10" />
<div className="bg-card bg-dots" />
</div>
</div>
<h3 className="mt-5 text-sm font-medium">线条网格</h3>
<p className="mt-2 text-xs leading-6 text-muted-foreground">
向左或向右拖动,翻到下一张。
</p>
</CardStackItem>
<CardStackItem>
<div
className="flex h-32 items-end gap-2 rounded-md border border-edge bg-dots p-4"
aria-hidden="true"
>
<div className="h-1/3 flex-1 rounded-sm bg-primary/20" />
<div className="h-2/3 flex-1 rounded-sm bg-primary/40" />
<div className="h-full flex-1 rounded-sm bg-primary" />
</div>
<h3 className="mt-5 text-sm font-medium">层次与节奏</h3>
<p className="mt-2 text-xs leading-6 text-muted-foreground">
细微的偏移,让层次自然浮现。
</p>
</CardStackItem>
<CardStackItem>
<div
className="h-32 rounded-md border border-edge bg-hatch"
aria-hidden="true"
/>
<h3 className="mt-5 text-sm font-medium">留白也有纹理</h3>
<p className="mt-2 text-xs leading-6 text-muted-foreground">
也可以用方向键和下方按钮切换。
</p>
</CardStackItem>
<CardStackControls />
</CardStack>
</div>
)
}
指标卡
卡片内部可直接组合 stat-card 的标题、数值和趋势原语。堆叠本身不依赖统计卡;该 registry 依赖供此示例组合使用。
"use client"
import {
CardStack,
CardStackControls,
CardStackItem,
} from "@/components/ui/card-stack/card-stack"
import {
StatLabel,
StatTrend,
StatValue,
} from "@/components/ui/stat-card/stat-card"
const STATS = [
{ name: "本月访问", value: 38642, trend: 12.4, unit: "次" },
{ name: "组件安装", value: 8240, trend: 18.6, unit: "次" },
{ name: "平均构建", value: 42.6, trend: -8.1, unit: "秒", invert: true },
]
export default function CardStackStatsDemo() {
return (
<div className="w-full max-w-xs px-2 py-4">
<CardStack label="站点指标">
{STATS.map(({ name, value, trend, unit, invert }) => (
<CardStackItem key={name} className="flex flex-col gap-4">
<StatLabel>{name}</StatLabel>
<StatValue value={value} unit={unit} size="lg" />
<StatTrend value={trend} invert={invert}>
较上月
</StatTrend>
</CardStackItem>
))}
<CardStackControls />
</CardStack>
</div>
)
}
API
CardStack
也可用直接子级 CardStackItem;两者择一。
类型React.ReactNode[] | undefinedCardStackMode | undefinednumber | undefinednumber | undefined每层向下偏移的像素。
类型number | undefined每层缩小的比例,例如 0.04。
类型number | undefinednumber | undefinednumber | undefined((index: number) => void) | undefinedstring | undefinedPartial<CardStackLabels> | undefined| 属性 | 默认值 | 说明 |
|---|---|---|
mode | auto | auto 定时轮换;drag 手动拖动 |
interval | 4000 | 轮换间隔,毫秒,最小 1000 |
depth | 3 | 可见层数,限制在卡片数量以内 |
offset | 12 | 每层向下偏移的像素,最小 0 |
scale | 0.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。
源码
"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,
}
"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 }
"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 }
"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 }
"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 }