# 3D 轮播 3D Carousel > 一圈卡片立在空中,可以拖着转,松手停到最近的一张,正对的那张最亮。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](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. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:6 到 12 个作品、产品系列、活动嘉宾,要有展示感。 - 不适合:要比较很多信息的商品;卡片少于 5 张;后台。 - 适合的网站:作品集、活动页、电商、官网 - 放在:首屏、内容区 - 手机上:要改:半径和卡片尺寸按屏宽缩小;手指拖动可以用。 - 性能代价:低:纯 CSS 3D。 - 常搭配:[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md)、[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `carousel-3d.tsx`(比如 `src/components/carousel-3d.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/carousel-3d.tsx),比手抄准:`curl -fsSL -o src/components/carousel-3d.tsx https://www.dachuiai.com/code/carousel-3d.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`Carousel3D` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的图片按名字拼出的 `/art/<名字>.jpg`。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:键盘左右键也能切换;onChange 回调当前正对的那张。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `items` | | 要显示的一组内容,格式见代码里的类型 | | `width` | `260` | 宽(px) | | `height` | `360` | 高(px) | | `radius` | `430` | 这一圈的半径(px),卡片多就调大 | | `onChange` | | 切换时调用,参数是新选中的那一项 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 作品展示做成 3D Carousel 3D 轮播:一圈卡片立在空中,可以拖着转,松手自动停到最近的一张,正对的那张最亮。 ## 完整代码 ```tsx import {useEffect, useRef, useState, type ReactNode} from 'react'; import {ChevronLeft, ChevronRight} from 'lucide-react'; /* --------------------------------------------------------------- * Carousel3D 3D 轮播 * 一圈卡片立在空中,拖着转,松手自动停到最近的一张;点两边也能切换。 * 键盘 ← → 切换。全部是 CSS 3D,每张卡片绕 Y 轴摆一个角度再往外推。 * ------------------------------------------------------------- */ type Props = {items: ReactNode[]; width?: number; height?: number; radius?: number; onChange?: (i: number) => void; className?: string}; export function Carousel3D({items, width = 260, height = 360, radius = 430, onChange, className = ''}: Props) { const stage = useRef(null); const ring = useRef(null); const cards = useRef<(HTMLDivElement | null)[]>([]); const n = items.length, step = 360 / n; const s = useRef({angle: 0, target: 0, drag: false, lx: 0, v: 0, moved: 0}); const cb = useRef(onChange); cb.current = onChange; const go = (d: number) => { s.current.target = Math.round(s.current.target / step) * step + d * step; }; const goRef = useRef(go); goRef.current = go; useEffect(() => { const el = stage.current!, st = s.current; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; let raf = 0, last = -1; const tick = () => { const ringElement = ring.current; if (!ringElement) return; raf = requestAnimationFrame(tick); if (!st.drag) st.angle += (st.target - st.angle) * (reduce ? 1 : 0.1); ringElement.style.transform = `translateZ(${-radius}px) rotateY(${st.angle}deg)`; cards.current.forEach((c, i) => { if (!c) return; const a = (((i * step + st.angle) % 360) + 360) % 360, near = Math.cos((a * Math.PI) / 180); // 1 = 正对 (c.firstElementChild as HTMLElement).style.filter = `brightness(${0.32 + 0.68 * Math.max(0, near) ** 2})`; }); const idx = ((Math.round(-st.target / step) % n) + n) % n; if (idx !== last) { last = idx; cb.current?.(idx); } }; raf = requestAnimationFrame(tick); const down = (e: PointerEvent) => { st.drag = true; st.lx = e.clientX; st.v = 0; st.moved = 0; el.setPointerCapture(e.pointerId); el.dataset.grab = '1'; }; const move = (e: PointerEvent) => { if (!st.drag) return; const d = (e.clientX - st.lx) * 0.28; st.lx = e.clientX; st.angle += d; st.v = st.v * 0.7 + d * 0.3; st.moved += Math.abs(d); }; const up = () => { if (!st.drag) return; st.drag = false; delete el.dataset.grab; st.target = Math.round((st.angle + st.v * 12) / step) * step; // 把甩出去的速度也算上 }; const key = (e: KeyboardEvent) => { if (e.key === 'ArrowLeft') { e.preventDefault(); goRef.current(1); } if (e.key === 'ArrowRight') { e.preventDefault(); goRef.current(-1); } }; el.addEventListener('pointerdown', down); el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); el.addEventListener('keydown', key); return () => { cancelAnimationFrame(raf); el.removeEventListener('pointerdown', down); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); el.removeEventListener('keydown', key); }; }, [n, step, radius]); const arrow = 'absolute top-1/2 z-10 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 text-white ring-1 ring-white/15 backdrop-blur transition hover:bg-white/20 active:scale-95 focus-visible:outline-2 focus-visible:outline-white'; return (
{items.map((it, i) => (
{ cards.current[i] = el; }} className="absolute inset-0 [backface-visibility:hidden]" style={{transform: `rotateY(${i * step}deg) translateZ(${radius}px)`}}>
{it}
))}
); } /* ---------------- 示例:一圈作品 ---------------- */ const ART = [['fluid-rose', '颜料'], ['planet', '远行星'], ['aurora', '极光'], ['dunes', '沙丘'], ['ocean', '光束'], ['lava', '地火'], ['ice', '蓝冰'], ['sunset', '落日']]; export default function Carousel3DDemo() { const [i, setI] = useState(0); return (
(
{t}
{t}
))} />

{String(i + 1).padStart(2, '0')} / {String(ART.length).padStart(2, '0')}

); } ```