# 卡片堆叠 Stacking Cards > 往下滚时下一张卡片盖住上一张,被盖住的缩小、变暗。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:3 到 6 个步骤、方案或案例,一张卡讲一件事。 - 不适合:卡片超过 6 张(滚太久);需要并排比较的内容,比如价格方案。 - 适合的网站:SaaS、官网、作品集、活动页 - 放在:内容区 - 手机上:好用:卡片高度控制在一屏的七成以内。 - 性能代价:低:sticky 加 transform。 - 常搭配:[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md)、[数字滚动 Count Up](https://www.dachuiai.com/c/count-up.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `stacking-cards.tsx`(比如 `src/components/stacking-cards.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/stacking-cards.tsx),比手抄准:`curl -fsSL -o src/components/stacking-cards.tsx https://www.dachuiai.com/code/stacking-cards.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`StackingCards` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/art/silk-violet.jpg](https://www.dachuiai.com/art/silk-violet.jpg)、[/art/silk-amber.jpg](https://www.dachuiai.com/art/silk-amber.jpg)、[/art/fluid-teal.jpg](https://www.dachuiai.com/art/fluid-teal.jpg)、[/art/sunset.jpg](https://www.dachuiai.com/art/sunset.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 钉住效果靠 `position: sticky`:外面任何一层写了 `overflow: hidden` 或 `overflow-x: hidden`(常见在 body、main 上)都会失效,要防横向滚动条改用 `overflow-x: clip`。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `children` | | 包在里面的内容 | | `height` | `420` | 每张卡的高(px) | | `gap` | `36` | 卡片之间滚动多远(px) | | `offset` | `14` | 每张卡比上一张低多少停住(px) | | `top` | `28` | 第一张卡停在离顶部多远(px) | | `scroller` | | 滚动盒子的 ref;整页滚动时不传 | ## 你可以说 > 这几个步骤做成 Stacking Cards 卡片堆叠:往下滚时下一张卡片盖住上一张,被盖住的卡片缩小、变暗。 ## 完整代码 ```tsx import {useEffect, useRef, type ReactNode, type RefObject} from 'react'; /* --------------------------------------------------------------- * StackingCards 卡片堆叠 * 往下滚时,一张卡片盖住上一张,被盖住的卡片缩小、变暗,退到后面。 * 每张卡片都是 position: sticky,停在比上一张低一点的位置; * 缩小和变暗按滚动进度算,只改 transform 和 opacity。 * 开了「减弱动态效果」时卡片照常一张张叠上去,只是不缩小、不变暗。 * 整页用时 scroller 不传;放在可滚动的盒子里时,传这个盒子。 * ------------------------------------------------------------- */ type Props = {children: ReactNode[]; height?: number; gap?: number; offset?: number; top?: number; scroller?: RefObject}; export function StackingCards({children, height = 420, gap = 36, offset = 14, top = 28, scroller}: Props) { const root = useRef(null); const wraps = useRef<(HTMLDivElement | null)[]>([]); const n = children.length; const step = height + gap; useEffect(() => { const el = scroller?.current; const target = el ?? window; const reduce = matchMedia('(prefers-reduced-motion: reduce)'); let raf = 0; const paint = () => { raf = 0; const group = root.current; if (!group) return; // 这一组卡片的顶端已经滚过停靠线多少 const y = top - (group.getBoundingClientRect().top - (el ? el.getBoundingClientRect().top : 0)); wraps.current.forEach((w, i) => { if (!w) return; // 第 i 张被后面几张盖住的程度:0 没盖,1 盖住一张,2 盖住两张……减弱动态效果时不缩小、不变暗 const covered = reduce.matches ? 0 : Math.min(n - 1 - i, Math.max(0, (y - i * step) / step)); const inner = w.firstElementChild as HTMLElement; inner.style.transform = `scale(${1 - covered * 0.05})`; (inner.querySelector('[data-shade]') as HTMLElement).style.opacity = String(Math.min(0.62, covered * 0.38)); }); }; const read = () => { if (!raf) raf = requestAnimationFrame(paint); }; target.addEventListener('scroll', read, {passive: true}); reduce.addEventListener('change', read); read(); return () => { target.removeEventListener('scroll', read); reduce.removeEventListener('change', read); cancelAnimationFrame(raf); }; }, [scroller, n, step, top]); return (
{children.map((c, i) => (
{ wraps.current[i] = el; }} className="sticky" style={{top: top + i * offset, height, marginBottom: i === n - 1 ? 0 : gap}} >
{c}
))}
); } /* ---------------- 示例:四个步骤 ---------------- */ const CARDS = [ {n: '01', title: '说出你的想法', text: '用一句话描述想做什么,不用懂代码。', img: '/art/silk-violet.jpg', dark: true}, {n: '02', title: '说对名字', text: '叫得出元件和动效的名字,AI 才改得对。', img: '/art/silk-amber.jpg', dark: false}, {n: '03', title: '一次一处', text: '每次只改一个地方,改完马上看效果。', img: '/art/fluid-teal.jpg', dark: false}, {n: '04', title: '发布上线', text: '一个人,一个下午,一个能用的产品。', img: '/art/sunset.jpg', dark: true}, ]; export default function StackingCardsDemo() { const box = useRef(null); return (

四步做出你的产品

{CARDS.map((c) => (
{c.n}

{c.title}

{c.text}

))}
); } ```