做产品落地页的时候客户丢过来一张参考图是那种能绕着圈转的卡片墙鼠标往左一拖整个环跟着转松手还带一点惯性转到正前方的那张自动放大高亮。我第一反应是搜现成的轮播插件翻了好几个库之后发现能做这种旋转木马效果的要么体积大得离谱要么参数暴露得一塌糊涂改个样式要翻半天源码。后来干脆用 htmlcssjs 从零撸了一个两个晚上的时间核心代码不到两百行可控性强得多。这篇就把这套旋转木马轮播图的完整实现拆开讲。它适合三类人刚学完 CSS3 3D 变换想找个真实案例练手的手里有个项目需要这种展示形式但又不想引第三方库的以及做过普通轮播、想搞清楚环形排布和轨道平移到底差在哪的。读完之后你应该能独立写出一个支持拖拽、惯性、自动播放、响应式的版本并且知道每一行参数为什么这么写。1. 普通轮播和旋转木马差的不只是动画1.1 一个是平行轨道一个是环形排布大部分人对轮播的理解是一排图片横向平移。这种结构的坐标系是一维线性的所有卡片都在同一条直线上位置用translateX(i * step)就能描述索引 i 从 0 数到 n-1越界了就把第一张挪到最后一张后面。简单、稳定、兼容性好这也是为什么百分之九十的轮播插件都是这个路子。旋转木马完全是另一回事。它的坐标系是柱面的卡片不是排在一条线上而是均匀分布在一个假想的圆柱面上每张卡片都有自己的旋转角度和向外的半径偏移量。你看到的转动本质上是整个圆柱在绕自己的中轴旋转而不是卡片在平移。这个差别听起来只是描述方式不同但它直接决定了几件事卡片之间的遮挡关系是动态的正前方的卡片会盖住侧后方的每张卡片的亮度、缩放、模糊度都需要根据它偏离正前方的角度实时计算鼠标拖拽映射的也不再是像素位移而是旋转角度。理解了这一点后面所有的公式都变得顺理成章。你会发现在柱面坐标系里真正需要计算的量只有两个相邻卡片的夹角和圆柱的半径。这两个数一旦定下来剩下的就是把当前转到第几张这个状态映射成一圈旋转角度交给 CSS 去渲染。1.2 三条实现路线我为什么选了中间那条动手之前我对比过三种做法列个表更清楚路线核心思路优点缺点适用场景纯 CSS 3D用:hover、animation配合rotateY硬编码角度零 JS代码短无法拖拽无法动态增删卡片循环切换做不了展示型静态环、Loading 动画JS 计算 CSS 3DJS 算角度写进 CSS 变量CSS 负责 3D 渲染交互自由性能好改动成本低需要理解 3D 变换的几个坑绝大多数产品页、作品集Canvas / WebGL自己算投影矩阵逐帧绘制极致性能可做卡面曲面变形开发成本高DOM 语义丢失SEO 不友好卡片数超过 50、需要真实光照我最终选的是中间这条。理由很实际卡片数量通常在 6 到 12 张之间DOM 完全扛得住CSS 的transition和cubic-bezier缓动是浏览器原生实现的比自己写 requestAnimationFrame 补间要平滑而且卡片里可以正常放图片、文字、按钮比 Canvas 里画文字舒服太多。WebGL 那条路只有在卡片量级很大或者需要卡片弯成弧形贴面的时候才值得普通项目走上去就是给自己找麻烦。2. 把舞台搭起来DOM 骨架和 3D 坐标系的建立2.1 为什么非要在列表外面再包一层先看结构。很多人第一版会写成这样容器里直接放一排卡片给容器加transform-style: preserve-3d。这么写早期能跑但后面想加整体缩放或者整体上下浮动的时候就会打架——因为容器的 transform 既管着整个环的旋转又管着别的视觉效果改一个另一个就崩。我的做法是拆成三层!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1 title3D 旋转木马轮播/title /head body !-- 第一层交互容器负责接事件、控制透视 -- div classcarousel idcarousel !-- 第二层舞台只负责整体旋转 -- div classstage idstage !-- 第三层卡片各管各的角度和半径 -- div classcardimg src1.jpg altspan classcap第一张/span/div div classcardimg src2.jpg altspan classcap第二张/span/div div classcardimg src3.jpg altspan classcap第三张/span/div div classcardimg src4.jpg altspan classcap第四张/span/div div classcardimg src5.jpg altspan classcap第五张/span/div div classcardimg src6.jpg altspan classcap第六张/span/div /div /div /body /html分层的好处是职责单一。carousel是透视的观察者stage是那个转动的圆柱体card是贴在圆柱面上的每一片。以后想加整个环上下轻微浮动就改 stage想加点击放大展示就改 card互不干扰。这种结构上的克制在项目后期加需求的时候能省下大量重构时间。2.2 perspective 的取值不是越大越好perspective决定了观察者离舞台有多远。数值越小透视越强烈近处卡片显得巨大、远处缩得极小视觉冲击力强但容易变形数值越大越接近正交投影看起来平。我一开始图省事给了800px结果 240px 宽的卡片转到两侧的时候被拉成了梯形文字都糊了。后来一路往上调实测下来这套参数比较稳卡片宽度 240px、舞台高度 420px 的配置下perspective: 1400px左右最舒服一般取卡片宽度的 5 到 7 倍是个不错的起点想要更夸张的立体感可以降到 1000px但别低于卡片宽度的 3 倍否则边缘卡片会明显畸变。还有一个容易被忽略的属性是perspective-origin默认是50% 50%也就是从正中间看。如果你的轮播在页面里偏左或者偏上建议显式写上眼睛的落点否则整个环看起来是歪的。.carousel { position: relative; height: 420px; perspective: 1400px; perspective-origin: 50% 45%; /* 视线略高于卡片中心俯视感更自然 */ overflow: hidden; touch-action: pan-y; /* 关键允许纵向滚动横向留给拖拽 */ }touch-action: pan-y这行必须加。不加的话移动端手指往下滑想翻页面会被浏览器判定成在操作轮播页面滚不动用户体验直接崩掉。2.3 preserve-3d 失效的三种原因transform-style: preserve-3d是让子元素保留独立 3D 空间的开关但它在三种情况下会莫名其妙失效我在调试时都撞过第一种是中间层被设置了overflow: hidden。浏览器一旦需要裁剪内容就会把这个元素压平成 2D 平面子元素的 3D 变换全部降级。解决办法是把裁剪放到最外层容器上中间层保持干净。第二种是给元素加了filter、opacity小于 1、clip-path或mask。这些属性会触发分组渲染同样会把 3D 上下文压平。我一开始想用filter: blur()做远处的虚化结果整个环瞬间变成一张平面图找了一个多小时才反应过来。第三种是父元素设置了will-change或contain: paint也会破坏 3D 层级。所以我的原则是stage 这一层除了 transform什么都别加。想加视觉效果加在卡片上或者最外层容器上。3. 半径和角度的算法直接决定会不会穿模3.1 半径公式推导以及一个实测修正系数卡片数量为 n 的时候均匀分布在圆周上相邻两张的夹角是θ 360° / n。要让它们看起来首尾相接也就是说相邻两张卡片的中心距离弦长约等于卡片宽度 w用圆的弦长公式反推弦长 2R · sin(θ / 2) w 得到 R (w / 2) / sin(180° / n)拿 6 张 240px 宽的卡片举例θ 60°sin(30°) 0.5所以R 120 / 0.5 240px。8 张的话sin(22.5°) ≈ 0.3827R 120 / 0.3827 ≈ 313px。但按这个公式算出来的半径实际效果会挤得有点紧卡片边缘几乎贴在一起转动的时候视觉上分不开。因为公式算的是刚好相切而人眼需要间隙来区分前后关系。所以实际操作中我会乘一个间隙系数卡片数量公式半径w240建议系数实际取值41701.2521262401.2028883131.15360103881.10427124641.08501规律很明确卡片越多夹角越小公式本身已经给出了足够的间隙系数就越接近 1卡片越少越需要额外拉开。另外如果卡片中间还有说明文字或者按钮系数可以再往上加 0.1。别怕半径大半径大只是环更扁视觉上并不会难看半径小的直接后果就是卡片互相穿插那才是真的救不回来。3.2 用自定义属性把参数收口参数一多最怕的就是散落在各处。我把所有可变量都收进 CSS 自定义属性JS 只需要改一个变量就能驱动整个环.stage { --count: 6; /* 卡片总数 */ --step: calc(360deg / var(--count)); /* 相邻夹角 */ --radius: 300px; /* 圆柱半径 */ --rotate: 0deg; /* 整体旋转角JS 下发 */ position: absolute; inset: 0; transform-style: preserve-3d; transform: rotateY(var(--rotate)); transition: transform .65s cubic-bezier(.22, .61, .36, 1); } .card { position: absolute; top: 50%; left: 50%; width: 240px; height: 320px; margin: -160px 0 0 -120px; /* 用负 margin 居中不污染 transform */ border-radius: 14px; overflow: hidden; background: #1c1f26; /* 核心先绕 Y 轴转再沿自己的 Z 轴推出去 */ transform: rotateY(calc(var(--i) * var(--step))) translateZ(var(--radius)); transform-origin: center center; }这里有两个细节值得展开。第一居中用负 margin 而不是translate(-50%, -50%)。因为translate也是 transform 的一部分写在最前面会和后面的rotateY混在同一个变换链里调试时你根本分不清哪个数值影响了哪个效果。负 margin 是布局层面的干净。第二rotateY必须在translateZ前面。变换链是从右往左作用在元素上的先沿着元素自己的 Z 轴往外推再把整个元素绕 Y 轴转。如果顺序反过来写成translateZ(...) rotateY(...)所有卡片会被推到同一个点再各自旋转全部叠在一起。这个顺序错一次就全废我第一次写就是这么翻车的。3.3 景深梯度让远处的卡片自动退到后面环转起来之后有个视觉问题如果没有明暗和透明度变化侧后方的卡片和正前方的卡片在视觉上一样实看起来像一堆卡片糊在一起没有纵深感。做法是用 JS 算每张卡片当前偏离正前方的程度映射成一个 0 到 1 的系数--k然后让 CSS 去消费.card { opacity: calc(1 - var(--k, 0) * .6); filter: brightness(calc(1 - var(--k, 0) * .45)) saturate(calc(1 - var(--k, 0) * .3)); transition: opacity .3s linear, filter .3s linear; box-shadow: 0 18px 40px rgba(0, 0, 0, calc(.45 - var(--k, 0) * .25)); } .card.is-active { transform: rotateY(calc(var(--i) * var(--step))) translateZ(calc(var(--radius) 60px)) scale(1.04); box-shadow: 0 30px 60px rgba(0, 0, 0, .5); }--k为 0 表示正前方为 1 表示正后方。映射到亮度和透明度上远处的卡片自动暗下去、淡出去纵深感就出来了。注意选中态额外加了 60px 的translateZ这个弹出是沿着卡片旋转之后的局部 Z 轴方向往外推的——旋转到正前方时局部 Z 轴正好指向屏幕外侧所以看起来就是往观众的方向弹出来转到侧面时同样弹出但方向偏了视觉上不会抢戏。4. JS 要做的事其实只有三件4.1 连续位置变量和取模归一化很多人写轮播习惯用一个整数index表示当前第几张。但拖拽过程中位置是连续的——你可能停在第三张和第四张之间。所以我用的是一个浮点变量position整数部分表示转到哪张小数部分表示偏移。卡片索引和 position 的差值需要做取模归一化否则算角度差的时候会出现从最后一张转回第一张要绕一大圈的问题const carousel document.getElementById(carousel); const stage document.getElementById(stage); const cards Array.from(stage.querySelectorAll(.card)); const COUNT cards.length; const STEP 360 / COUNT; let position 0; // 连续位置可正可负可无限增长 let dragging false; // 把任意差值归一化到 [-COUNT/2, COUNT/2]保证走最短路径 function normalize(diff) { let d diff % COUNT; if (d COUNT / 2) d - COUNT; if (d -COUNT / 2) d COUNT; return d; }为什么一定要归一化因为position会一直累加转十圈之后它可能等于 17.3。如果不取模index - position算出来是 -17 左右你去映射透明度时会得到一个巨大的数字全部卡片变成透明的。取模之后映射到[-3, 3]这个区间6 张卡的情况绝对值正好对应当前偏离了多少张逻辑就自洽了。4.2 渲染函数只写变量不写样式渲染逻辑我全部塞进一个函数它只做两件事把整体角度写到 stage 的 CSS 变量上把每张卡片的偏离系数写到卡片自己的变量上。function render(animate true) { stage.style.transition animate ? transform .65s cubic-bezier(.22, .61, .36, 1) : none; stage.style.setProperty(--rotate, (-position * STEP) deg); cards.forEach((card, i) { card.style.setProperty(--i, i); const k Math.min(Math.abs(normalize(i - position)) / (COUNT / 2), 1); card.style.setProperty(--k, k.toFixed(3)); card.classList.toggle(is-active, Math.abs(normalize(i - position)) 0.5); }); }注意--i每张卡片是固定值其实可以在初始化时写一次就不动了这里放在 render 里只是为了让循环完整实际项目里我会挪到初始化去减少每帧的属性写入。这是个小优化但卡片多起来的时候能省几十次 DOM 操作。真正优雅的地方在于JS 从头到尾没有直接改过任何transform字符串。它只写 CSS 变量剩下的交给 CSS 的calc()和transition。好处是缓动曲线、过渡时长这些表现层的东西全在样式表里运营想改动画快慢不用找你改 JS改一行 CSS 变量就行。4.3 循环和自动播放因为position可以无限增长循环基本不用特殊处理——转到第 7 张6 张卡的情况时取模之后它自然就对应第 1 张的位置。但有个细节如果position一直涨转个几百圈之后会变成 1e5 这种量级虽然取模计算没问题但浮点精度会慢慢受影响。我的处理是在每次动画结束之后做一次虚拟归位stage.addEventListener(transitionend, () { const settled Math.round(position); if (Math.abs(settled) COUNT) { position - Math.trunc(settled / COUNT) * COUNT; // 减掉整圈 render(false); // 无动画重绘 } });这段的作用是转满一圈之后把 position 减掉一圈的整数倍视觉上完全看不出变化因为角度模 360 是一样的但数值保持在[-COUNT, COUNT]这个小范围里。这是长时间运行的页面里必须做的维护不然跑几个小时之后可能会看到莫名其妙的抖动。自动播放就是定时器推动 positionlet autoTimer null; function startAuto(interval 3200) { stopAuto(); autoTimer setInterval(() { position Math.round(position) 1; render(); }, interval); } function stopAuto() { if (autoTimer) clearInterval(autoTimer); autoTimer null; }Math.round(position) 1而不是position 1是为了保证不管当前停在哪下一跳都从整数位置开始避免误差累积导致越转越偏。5. 拖拽惯性、自动播放和它们之间的打架5.1 用 pointer 事件统一鼠标和触摸早期我分开写mousedown/mousemove/mouseup和touchstart/touchmove/touchend两套逻辑要维护两遍而且触摸设备的passive行为和鼠标差异很大。现在统一用 Pointer Events一套代码通吃let startX 0, startPosition 0, lastX 0, lastTime 0, velocity 0; const DEG_PER_PX 0.22; // 每像素对应多少度方向反了就取负 carousel.addEventListener(pointerdown, (e) { dragging true; startX lastX e.clientX; startPosition position; lastTime performance.now(); velocity 0; carousel.setPointerCapture(e.pointerId); // 关键手指移出容器也不丢事件 stopAuto(); }); carousel.addEventListener(pointermove, (e) { if (!dragging) return; const dx e.clientX - startX; position startPosition - (dx * DEG_PER_PX) / STEP; const now performance.now(); const dt Math.max(now - lastTime, 1); velocity (e.clientX - lastX) / dt; // px / ms lastX e.clientX; lastTime now; render(false); // 拖拽中不要过渡否则会滞后于手指 }); carousel.addEventListener(pointerup, endDrag); carousel.addEventListener(pointercancel, endDrag);setPointerCapture这行非常重要。不加的话鼠标快速拖动移出容器范围pointerup事件就被外面的元素接走了你的dragging永远是 true轮播会一直跟着鼠标乱转。我在这上面浪费了半个下午。5.2 惯性系数是调出来的不是算出来的松手之后的处理分两步先根据松手瞬间的速度决定多转几格再把位置吸附到整数。function endDrag() { if (!dragging) return; dragging false; // 惯性按 200ms 的缓动时间估算额外位移 const extraDx velocity * 200; position - (extraDx * DEG_PER_PX) / STEP; // 吸附到最近的整数位置 position Math.round(position); render(); // 3 秒后恢复自动播放 clearTimeout(endDrag._t); endDrag._t setTimeout(startAuto, 3000); }这里的200是经验系数代表惯性还能滑行多久。调大到 300 就是那种滑一下就转好几张的爽快感调到 80 就变成很克制的滑一张就停。这个值没有理论最优解取决于你的卡片宽度和整体调性。我做企业官网的时候用 150做潮流品牌的展示页用 280差异很明显。velocity的单位是 px/ms正常鼠标滑动大概在 1 到 3 之间触屏快速滑动能到 5 以上。如果你发现甩一下能转五六张说明系数太大了往下调。5.3 自动播放的三个暂停时机自动播放看起来就是setInterval但它和用户操作有冲突必须在三个时机暂停第一个是指针按下时也就是pointerdown里调stopAuto()不然你正拖着它自己还在转手感极其诡异。第二个是鼠标悬停在容器上时用户想仔细看某张卡片的内容你还在转就很烦。第三个是页面切到后台时用document.visibilitychange监听切回来再恢复。浏览器虽然会节流后台定时器但恢复时机不好控制主动管理更稳。carousel.addEventListener(pointerenter, stopAuto); carousel.addEventListener(pointerleave, () { if (!dragging) startAuto(); }); document.addEventListener(visibilitychange, () { document.hidden ? stopAuto() : startAuto(); });暂停之后恢复的时间点也有讲究。我一开始是松手立刻恢复计时器结果是用户刚松手不到半秒卡片又自己动了感觉像没被尊重。后来改成延迟 3 秒再启动体验一下子好了很多。这种细节写需求文档的人不会提但用户能明显感觉到。6. 我在实测中踩到的几个坑6.1 卡片的背面镜像和边缘闪烁旋转到背面的时候卡片的正反面会同时被渲染出现镜像文字。默认情况下浏览器是双面可见的backface-visibility: visible你需要显式关掉.card { backface-visibility: hidden; -webkit-backface-visibility: hidden; /* 老版本 WebKit 内核仍需前缀 */ }但这里有个坑如果卡片本身就是个双面翻转的效果比如一面是图片一面是文字说明那你就不能关掉背面可见性。这种情况下的正确做法是给卡片的内层子元素设置transform: rotateY(180deg)加上backface-visibility: hidden做成真正的双面板结构而不是靠浏览器帮你隐藏。另一个是边缘闪烁问题。在某些显卡驱动下卡片边缘会出现一条一像素的白色缝隙或者轻微抖动。原因是浏览器在合成 3D 图层时做了亚像素处理。解决办法有两个给卡片加outline: 1px solid transparent强制完整的图层边界或者把translateZ的半径值取整别用 300.5 这种小数。我用的是前者加上之后就再没出现过。6.2 filter 和 box-shadow 在 3D 里的性能陷阱前面第 3.3 节我给卡片加了filter: brightness()做远处的暗化。这个方案在卡片数量少的桌面上没问题但我在一台旧安卓机上测的时候掉帧掉得厉害滑动明显卡顿。原因是filter会触发离屏渲染而且它会压平 preserve-3d前面提过的第二种失效原因。虽然视觉上看起来还是立体环但实际上浏览器已经退化成平面合成再模拟性能开销翻倍。后来我改成了两层做法用opacity做淡化开销低暗化用一个铺在卡片上的伪元素黑色蒙版.card::after { content: ; position: absolute; inset: 0; background: #000; opacity: calc(var(--k, 0) * .5); pointer-events: none; transition: opacity .3s linear; }改完之后的帧率提升很明显。box-shadow也有类似的坑阴影扩散半径越大重绘越贵。我的做法是给卡片用box-shadow但不要在 transition 里改它的数值——改数值会触发每帧重绘改成用透明度变化来模拟阴影的强弱会便宜很多。6.3 移动端想滑动页面却触发了旋转这个坑是最影响体验的。用户手指在轮播区域竖向滑动想翻页结果被识别成横向拖拽轮播转了一格页面纹丝不动。解决方案分两层。第一层是前面提到的touch-action: pan-y把横向手势留给 JS纵向交给浏览器。第二层是在 JS 里做方向判定按下之后的头 10px 位移里如果竖向位移明显大于横向就直接判定为页面滚动放弃这次拖拽。let decided null; // null 未判定x 横向y 纵向 // pointermove 里 const dy Math.abs(e.clientY - startY); const dx Math.abs(e.clientX - startX); if (!decided) { if (dx 6 dy 6) return; // 还没动够等等看 decided dx dy ? x : y; } if (decided y) { dragging false; return; }6px 这个阈值是试出来的。阈值太小手指轻微抖动就误判阈值太大用户已经滑了一段才开始响应感觉迟钝。不同设备屏幕密度不一样如果想更严谨可以按devicePixelRatio缩放一下这个值。7. 收尾的一些补丁和扩展思路7.1 半径要跟着视口重算桌面上半径 360px 很合适到了 375px 宽的手机上就是灾难——卡片直接飞出屏幕外。所以半径必须响应式。我的做法是在 CSS 里用clamp()把半径绑在视口宽度上同时用 JS 在resize时重算卡片数量影响的那部分。.stage { /* 最小 180px理想值是视口宽度的 42%最大 320px */ --radius: clamp(180px, 42vw, 320px); }为什么用 42vw 而不是别的数这是实测出来的卡片宽度大概是视口宽度的 32% 时6 张卡片的环在这个比例下刚好能占满横向空间又不越界。卡片数量变了要重新调这个系数但换汤不换药。另外resize事件在移动端会因为地址栏的显示隐藏而高频触发直接在里面重算会很卡。记得加防抖200ms 就够了let resizeTimer null; window.addEventListener(resize, () { clearTimeout(resizeTimer); resizeTimer setTimeout(() { render(false); // 重新下发一次变量让 CSS 重新计算 }, 200); });7.2 键盘操作和减少动画偏好侧边卡片我加了点击直接跳转点击某张卡片时把position设成它的索引render()一下它就转到正前方来了。实现很简单但记得判断点击和拖拽的区别——如果这次指针交互产生了超过阈值的位移就当成拖拽不触发点击。card.addEventListener(click, () { if (movedDistance 8) return; // 拖拽过就不算点击 position i; render(); });键盘支持是很多人会忽略的。给容器加tabindex0和左右方向键监听能覆盖一批不用鼠标的用户。另外prefers-reduced-motion媒体查询也该处理一下——有些用户对动效敏感系统里开了减少动态效果这时候应该把过渡时长砍到接近 0而不是照样转media (prefers-reduced-motion: reduce) { .stage, .card { transition-duration: .01ms !important; } }7.3 还能往下做的几件事如果项目还有余量有几个方向可以继续加卡片内容用img的loadinglazy配合预加载前两张避免转过去的时候白屏给卡片加一点随角度变化的rotateX倾斜做出轻微的内凹感或者把整个环做成双排前后两圈反向旋转视觉复杂度会高不少但代码改动不大只是把卡片按奇偶分成两组分别设不同的半径和旋转方向。还有就是卡片数量动态化的场景如果是从接口拿数据再渲染记得渲染完之后重新读取COUNT和STEP别用初始化时缓存的值。我有个项目就是接口返回数量变了但角度没重算卡片全挤在一块排查了半天才发现是缓存了旧的常量。这套代码我后来在三个不同项目里复用改动量最大的一个也就换了半径系数和卡片尺寸核心逻辑一行没动。这也是我越来越喜欢JS 只写变量、CSS 负责渲染这个模式的原因——边界清楚的东西复用起来才不心虚。