ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

原生JS+CSS 3D旋转木马轮播图:可拖拽、自动播放与响应式

原生JS+CSS 3D旋转木马轮播图:可拖拽、自动播放与响应式 上个月朋友找我改一个活动落地页需求就一句话首屏要一个“能转起来的卡片轮播”像游乐场里那种旋转木马鼠标能拖、点按钮能转、没人操作的时候自己慢慢转。他先拿现成的轮播组件试了一圈结果要么是平面推拉、要么是封面流做不出那种“卡片围成一圈、转到背面的自动藏起来”的空间感。最后我们用原生 HTML CSS JS 撸了一个不到 200 行的旋转木马轮播图反而比引库更轻、更好控改样式也不用翻别人的源码。这套东西的原理其实很朴素把若干张卡片沿一个圆柱面均匀排开再让整个圆环绕 Y 轴旋转不同的卡片就轮流转到正对你的位置。它解决的是平面轮播“一眼看完、没有纵深”的问题特别适合产品展示、作品集、活动页首屏、相册这类需要让人“逛一逛”的场景。下面这套方案不依赖任何框架只要你能写 HTML、看得懂 CSS 的 transform、能写几行 JS 操作 DOM 就能跟着做如果你已经在用轮播组件也能从里面找到“为什么它转起来会抖”“为什么后面的卡片还是露出来了”这些问题的答案。1. 方案选型旋转木马轮播图凭什么比平面轮播更抓人1.1 从“推拉”到“绕环”变的不只是动效平面轮播的本质是“横向位移”所有卡片在同一平面上靠translateX把它们一张张推过去。它的信息密度很低——除了当前那张其余的都在屏幕外用户只能看到“有下一张”看不到“还有多少张”。旋转木马换了个思路卡片不再平移而是绕着一个虚拟圆柱体的中轴旋转正面的卡片大而清晰两侧的卡片带着明显的透视变形斜插进来背面的卡片自然隐藏。用户第一眼就能感知到“这是一个环内容有八张”这种“可探索感”是平面轮播给不了的。更关键的是绕环旋转天然自带纵深。人眼对透视缩小非常敏感同样是八张卡片平面排布时它们大小完全一致视觉上是一堆“平铺的图”绕环排布时正面卡片宽度可能是两侧卡片的 1.5 倍这种大小差异会自动帮用户建立空间层级知道哪张是主角。这也是为什么电商、展会、音乐类页面特别喜欢用这种形式。还有一个容易被忽略的好处旋转木马天然支持“循环”。平面轮播做到无缝循环通常要在首尾各复制一张卡片然后在动画结束时偷偷重置位置稍有不慎就会闪一下。而绕环方案里角度是可以无限累加的转 360 度和转 720 度在视觉上没有区别你根本不需要做任何“瞬移”处理循环就是免费的。1.2 三条实现路线的取舍真要做起来市面上大致有三条路我按自己的实际体验对比一下路线代表做法优点代价适合谁纯 CSS 动画keyframes让圆环持续 rotateY代码最少零 JS无法暂停/拖拽无法高亮当前项卡片数量写死在 CSS 里只做装饰性背景原生 JS 驱动 transform一个索引变量算出角度写进 style完全可控能拖能点能自动播放体积近乎为零需要自己处理拖拽、响应式、边界绝大多数真实项目第三方轮播库的 3D 模式依赖库的 coverflow / carousel 效果开箱即用体积大、定制困难、3D 模式往往是“伪 3D”背面处理不干净工期极紧、对效果要求不高我这次选的是中间那条。原因很实际需求里明确要“能拖拽 自动播放 当前卡片高亮”纯 CSS 一个都做不到而引库的话光是为了改一个卡片间距就得去翻它的 CSS 变量文档改动成本比手写还高。原生方案的核心代码其实只有三块——布局算半径、渲染算角度、交互算索引加起来不到一百行。还有一点值得说用第三方库做 3D 轮播很多时候你拿到的是“伪 3D”——库内部用scale模拟远近卡片始终是平的不会真的侧过去。这种效果在卡片数量少的时候勉强能看一旦卡片变多侧边卡片会像纸片一样僵在那边一眼假。真 3D 靠的是perspectivepreserve-3d这是浏览器原生能力不用白不用。1.3 先算账卡片数量、半径与可视范围动手写代码之前有两笔账必须先算清楚不然做出来大概率穿模或者露怯。第一笔是半径。卡片沿圆环排开它们的外接圆半径决定了卡片之间的间距。如果半径给小了相邻卡片会互相插进去给大了中间会空出一大块看着像散了架。这里用的是正多边形外接圆公式R (w gap) / (2 * tan(π / n))w是卡片宽度gap是你想要的卡片间距n是卡片数量。举个实际数字8 张卡片每张宽 320px希望间距留 40px那么tan(π/8) tan(22.5°) ≈ 0.4142代入得R (320 40) / (2 × 0.4142) ≈ 434.6px。这个半径意味着圆环中心到每张卡片中心的距离是 434.6px正面卡片会出现在离你 434.6px 的位置。第二笔是可视范围。加了backface-visibility: hidden之后只有法线朝向观众的那部分卡片能看见。一张卡片能被看见的条件是它相对正面的偏转角在 ±90° 以内所以可见数量大约是360 / 角度步长 / 2。八张卡片步长是 45°理论上能看到 3 张多一点——实测就是正面一张、两侧各一张斜插进来。如果你希望同时看到五张那卡片数量得加到 12 张步长 30°。这个数量关系很重要很多人做完发现“怎么只有三张卡”其实就是卡片数量太少。提示卡片数量少于 6 张时圆环会显得很“空”因为相邻卡片之间空出的角度太大。我的经验值是 8 到 12 张最舒服超过 14 张之后侧边卡片会被压得太扁反而不像卡片了。2. 搭骨架HTML 结构与 3D 舞台的三个关键属性2.1 DOM 只切三层就够了旋转木马的 DOM 结构比想象中简单三层足够外层舞台负责提供透视中间圆环负责整体旋转内层卡片负责各自的定位。div classstage div classring idring div classcard style--i: 0 img src1.jpg alt作品一 div classcard-meta作品一/div /div div classcard style--i: 1 img src2.jpg alt作品二 div classcard-meta作品二/div /div !-- 其余卡片同理--i 依次递增 -- /div /div这里用了一个小技巧把卡片序号写成内联的 CSS 变量--i。这样卡片的旋转角度可以直接在 CSS 里算出来JS 完全不用去逐个设置transform。八张卡片就写八个style--i: n如果卡片是从接口动态渲染的在模板字符串里拼进去也一样方便。为什么不把--i也交给 JS 设置因为设置内联样式意味着要遍历所有卡片、逐个写style.transform每次旋转都要重排一遍卡片一多就容易掉帧。写成 CSS 变量之后只有圆环那一层在改 transform卡片自己的 transform 是静态的浏览器可以直接复用。三层结构还有一个隐藏好处如果你后面想加“卡片内层内容反向旋转、始终正对观众”的效果只需要在.card里再套一层.card-inner改动范围极小不会牵动整体结构。2.2 perspective 与 transform-style 到底谁管谁这两个属性是 3D 轮播最容易搞混的地方我一开始也踩过。perspective加在父级上定义的是“眼睛离屏幕有多远”。它决定了透视的强度数值越小近大远小的效果越夸张卡片会像从隧道里冲出来数值越大透视越平缓接近正交投影。给.stage设perspective: 1400px是比较稳的选择——能看出纵深又不至于侧边卡片被拉变形。低于 800px 会开始有鱼眼感高于 2500px 基本就看不出立体了。transform-style: preserve-3d加在做 3D 变换的元素本身上告诉浏览器“我的子元素也在 3D 空间里别把我压平”。它必须加在圆环.ring上而不是舞台上。如果只加在舞台上圆环内部还是会退化成平面渲染卡片之间的前后遮挡关系全部消失你会看到所有卡片糊在同一层。注意preserve-3d有个很坑的特性——只要给这个元素加了overflow: hidden、filter、clip-path、mix-blend-mode等“分组属性”浏览器就会强制把它压平回 2D。我调这个效果的时候为了裁掉超出屏幕的部分顺手在.ring上写了overflow: hidden结果整个 3D 效果瞬间没了找了半小时才发现。要裁剪就得裁在舞台上而且舞台本身不能带preserve-3d。.stage { --radius: 435px; --step: 45deg; position: relative; width: 100%; height: 460px; padding: 20px 0; overflow: hidden; /* 裁在舞台上不裁在圆环上 */ perspective: 1400px; perspective-origin: 50% 45%; /* 眼睛略高于中线卡片顶部会稍微前倾 */ } .ring { position: relative; width: 320px; height: 400px; margin: 0 auto; transform-style: preserve-3d; transform: translateZ(calc(var(--radius) * -1)) rotateY(0deg); transition: transform .65s cubic-bezier(.25, .8, .3, 1); will-change: transform; }perspective-origin: 50% 45%这行是把“视点”稍微往上提了一点。默认的50% 50%会让视点落在圆环正中间卡片看起来是水平的提到 45% 之后你会从略高的位置俯视这个圆环卡片顶部会轻微后仰空间感更自然。这个值不用抠太细45% 到 40% 之间都行。还有个细节.ring的初始 transform 里有一句translateZ(calc(var(--radius) * -1))。这句是把整个圆环往后推一个半径让正面那张卡片恰好落在 z0 的平面上也就是和舞台齐平。如果不推这一下正面卡片会跑到 z435px 的位置离眼睛更近视觉上会被放大一圈而且容易顶出容器边界。这个“推回去”的操作是很多教程漏讲的但影响挺明显。2.3 卡片自己的旋转与位移每张卡片的 transform 由两段组成先绕 Y 轴转它该在的角度再沿旋转后的方向往外推一个半径。.card { position: absolute; inset: 0; border-radius: 16px; overflow: hidden; background: #1b1d22; box-shadow: 0 20px 40px rgba(0, 0, 0, .35); backface-visibility: hidden; /* 关键背面不渲染 */ transform: rotateY(calc(var(--i) * var(--step))) translateZ(var(--radius)); transition: box-shadow .4s ease, filter .4s ease, opacity .4s ease; } .card img { display: block; width: 100%; height: 100%; object-fit: cover; }注意 transform 的顺序不能反。rotateY必须写在前面translateZ写在后面。因为 transform 是从右往左作用在坐标系上的先translateZ的话卡片会沿着当前未旋转的朝向往外推结果就是所有卡片都推到同一个方向整个圆环变成了一摞纸。只有先转角度、再沿新朝向推出去卡片才会真正分布在一个圆周上。inset: 0让卡片铺满圆环这样卡片宽高就等于圆环的宽高后面算半径时可以直接用ring.offsetWidth取值省得再写一份尺寸常量。backface-visibility: hidden就是那个“魔法开关”——加上它转到背面法线朝外的卡片会被浏览器直接跳过渲染你看到的就是一个干净的前半圆。不加的话背面卡片的底图会反着透出来透过正面卡片的缝隙能看见非常脏。3. JavaScript 核心逻辑一个索引变量驱动整圈旋转3.1 渲染函数只改一个元素的 transform整个 JS 的核心是一个index变量加一个render()函数。index表示“当前哪张卡片应该转到正面”它可以是任意整数——负数、超过总数的数都没关系因为角度本身是循环的。const stage document.querySelector(.stage); const ring document.getElementById(ring); const cards Array.from(ring.querySelectorAll(.card)); const total cards.length; const step 360 / total; let index 0; function render(animated true) { const radius parseFloat( getComputedStyle(stage).getPropertyValue(--radius) ); if (!animated) ring.style.transition none; ring.style.transform translateZ(${-radius}px) rotateY(${-index * step}deg); if (!animated) { // 强制读一次布局让浏览器先应用“无过渡”的状态 void ring.offsetWidth; ring.style.transition ; } cards.forEach((card, i) { // 取模保证 index 为负数时也能正确判断 const offset ((i - index) % total total) % total; card.classList.toggle(is-active, offset 0); }); }这里有几个点值得展开说。第一旋转角度用的是-index * step是负的。因为卡片自己是正着转i * step度排布的圆环要反向转回来才能把第index张拉到正面。如果你写成正的会发现点“下一张”时卡片往反方向跑。第二判断当前卡片用的是((i - index) % total total) % total。这是 JS 里处理负取模的标准写法。因为index会一直累加或递减-1 % 8在 JS 里等于-1而不是7直接比较会漏判。加上 total再取一次模就稳了。第三animated参数和那句void ring.offsetWidth是为拖拽场景准备的。用户手指按住往下拖的时候圆环需要实时跟手不能有 0.65 秒的过渡否则会拖出严重的延迟感。这时候要先关掉 transition、改 transform、再强制浏览器读一次布局属性offsetWidth会触发重排最后把 transition 恢复。不读这一次布局的话浏览器可能把“关过渡”和“改 transform”合并成一帧处理导致恢复过渡之后再改 transform 时上一次的位置和这一次之间产生一段突兀的补间动画。3.2 按钮、自动播放与无限循环按钮点击就是加减index非常简单document.querySelector(.btn-prev).addEventListener(click, () { index - 1; render(); }); document.querySelector(.btn-next).addEventListener(click, () { index 1; render(); });注意这里完全没做index index % total之类的归一化。这是绕环方案最舒服的地方——不要归一化。因为rotateY(-720deg)和rotateY(0deg)视觉上完全一样所以index从 7 涨到 8 时角度从 -315° 变成 -360°卡片是继续往前转的不会像平面轮播那样突然倒着弹回去。如果你手贱写了归一化转到第八张再点下一张卡片就会猛地倒转一整圈非常难看。自动播放用一个setInterval配一个“用户交互后暂停”的逻辑就够let timer null; const DELAY 3800; function play() { stop(); timer setInterval(() { index 1; render(); }, DELAY); } function stop() { if (timer) { clearInterval(timer); timer null; } } stage.addEventListener(pointerenter, stop); stage.addEventListener(pointerleave, play); document.addEventListener(visibilitychange, () { document.hidden ? stop() : play(); });pointerenter/pointerleave做悬停暂停visibilitychange做标签页切走时暂停。后面这个监听别省——有些浏览器在标签页不可见时会限制setInterval的频率切回来之后积压的回调会一次性全部执行你能看到轮播图像疯了一样连转五六圈。加了这个监听就彻底解决了。还有个细节是DELAY取 3800ms 而不是常见的 3000ms因为我们的过渡时长是 650ms如果间隔太短用户还没看完当前卡片就开始往下一张转了。经验公式是“停留时间至少是过渡时长的 4 倍”。3.3 指针拖拽与惯性吸附拖拽是提升手感的关键实现思路是按下时记录起始位置和起始索引移动时算出平移了多少像素、折算成角度、实时写进 transform松手时把连续的角度取整到一个整数索引上再调一次带过渡的render()做吸附。const DRAG_PER_ITEM 120; // 每拖 120px 换一张 let dragging false; let startX 0; let startIndex 0; stage.addEventListener(pointerdown, (e) { dragging true; startX e.clientX; startIndex index; stage.setPointerCapture(e.pointerId); stop(); }); stage.addEventListener(pointermove, (e) { if (!dragging) return; const dx e.clientX - startX; const offset -dx / DRAG_PER_ITEM; // 向左拖为正对应 index 增加 const radius parseFloat( getComputedStyle(stage).getPropertyValue(--radius) ); ring.style.transition none; ring.style.transform translateZ(${-radius}px) rotateY(${-(startIndex offset) * step}deg); }); stage.addEventListener(pointerup, (e) { if (!dragging) return; dragging false; const dx e.clientX - startX; const offset -dx / DRAG_PER_ITEM; index startIndex Math.round(offset); render(); play(); }); stage.addEventListener(pointercancel, () { dragging false; render(); play(); });有几处值得说明。setPointerCapture是为了让指针滑出舞台之后仍然能收到pointermove和pointerup不然用户拖快一点手滑到页面上别的地方事件就断了圆环会僵在半路。DRAG_PER_ITEM 120这个值我调过好几次80 太灵敏随便一划就跳过两三张200 又太钝得拖很远才动一张120 左右是最跟手的换算过来大约相当于“拖过一张卡片宽度的三分之一就换一张”符合直觉。pointerup里用的是Math.round(offset)而不是Math.floor这样拖动不足半张时会自动弹回原位超过半张才切到下一张。这是吸附手感的关键——用floor的话只要你稍微动一下手指就会被判成换页用户会觉得“太滑了”。如果你的页面本身是纵向滚动的还得处理一下手势方向判定在pointermove里比较一下横向和纵向的位移如果纵向位移明显大于横向就主动放弃这次拖拽让页面正常滚动。不然在手机上用户想往下翻页却一直在转轮播体验非常糟。4. 动效与视觉打磨让它看起来“贵”一点4.1 缓动曲线怎么选transition的缓动函数直接决定了“转起来有没有质感”。默认的ease太温吞linear像机器人在转ease-in-out倒是也能用但启动和刹车都偏慢拖沓。我现在固定用这条transition: transform .65s cubic-bezier(.25, .8, .3, 1);这条曲线的前段加速很快.25, .8让进度在很短时间内就冲到 40% 以上后段慢慢收尾视觉上是“起步干脆、停下从容”。实测比ease-out更有分量感。时长方面650ms 是我试下来最舒服的区间中点。低于 400ms 会显得仓促卡片还没看清就过去了超过 900ms 又会让人觉得“这页面是不是卡了”。如果卡片数量多、每张之间角度小可以适当缩短到 500ms 左右因为转过的角度本身就小。4.2 景深、倒影与明暗层次真 3D 的纵深是浏览器算出来的但“好看”还得靠手调。我给侧边卡片加了两层处理一是降低亮度二是轻微模糊。.card { filter: brightness(.62) saturate(.85); transition: filter .45s ease, box-shadow .45s ease; } .card.is-active { filter: none; box-shadow: 0 28px 56px rgba(0, 0, 0, .5); }用filter而不是opacity是有原因的opacity会让卡片变透明透过它能看到后面的圆环和背景容易糊成一团brightness是直接压暗卡片仍然是实心的前后层次反而更清楚。另外filter会自动创建一个新的层对 GPU 合成也更友好。注意filter同样属于“分组属性”如果加在有preserve-3d的元素上会导致 3D 压平。这里是加在最底层的.card上它下面没有需要 3D 排布的子元素所以安全。千万别顺手把它加到.ring上。倒影效果可以用-webkit-box-reflect一行搞定.card img { -webkit-box-reflect: below 6px linear-gradient(transparent 55%, rgba(255, 255, 255, .18)); }这个属性虽然带厂商前缀但在主流浏览器里支持得都不错性能开销也小。配合深色背景卡片会像立在玻璃台面上质感提升明显。4.3 响应式屏幕变小时怎么重算半径半径是写死在 CSS 变量里的屏幕一变小卡片跟着缩但半径没缩卡片之间就会出现巨大的缝隙。解决办法是把半径的计算搬到 JS 里用ResizeObserver监听圆环宽度变化后重算。function layout() { const w ring.offsetWidth; const n cards.length; const gap 40; // 防穿模间距至少留 24px const radius Math.max((w gap) / (2 * Math.tan(Math.PI / n)), w * 1.1); stage.style.setProperty(--radius, radius.toFixed(1) px); render(false); } const ro new ResizeObserver(layout); ro.observe(ring); layout();Math.max那一下是防止极端情况如果卡片数量特别少比如 4 张公式算出来的半径会小于卡片自身宽度卡片会互相穿进去所以兜一个“半径至少是卡片宽度的 1.1 倍”的下限。另外在小屏上我一般会把卡片数量从 8 张减到 6 张同时把步长改成60deg。因为手机上卡片宽度只有 220px 左右如果还是 8 张侧边卡片会被压得只剩一条缝看不出是什么内容。减数量比缩尺寸更有效。实现上可以用matchMedia判断一次然后隐藏掉若干张卡片并重新计算total和step。if (window.matchMedia((max-width: 640px)).matches) { cards.slice(-2).forEach(c c.style.display none); // ...重新计算可见卡片数量后更新 total 与 step }5. 常见问题与排查技巧实录5.1 问题速查表下面这些问题基本都是我在这个项目里真实遇到过的按“现象 → 原因 → 解决”整理成表方便对着排查。现象根本原因解决方式后面的卡片图案反着透出来没加背面隐藏给.card加backface-visibility: hidden所有卡片糊在一层没有遮挡关系父级缺preserve-3d或被分组属性压平transform-style: preserve-3d加在圆环上同时移除圆环上的overflow/filter正面卡片比两侧还小、还远圆环没有往后推一个半径圆环 transform 前补translateZ(calc(var(--radius) * -1))点“下一张”卡片往反方向转角度符号写错圆环用-index * step卡片用i * step转到末尾再点会倒转一整圈对index做了取模归一化删掉归一化让index自由增长悬停时不暂停、切回标签页疯狂转缺少交互与可见性监听加pointerenter/visibilitychange拖拽时圆环迟钝、跟不上手拖拽过程中仍有 transition拖拽期间把transition设为none拖动松手后回弹位置不对用floor取整或没考虑起始索引用Math.round(offset)并加上startIndex手机上滑不动页面手势被轮播劫持在pointermove里比较横纵位移纵向占优时放弃拖拽边缘有锯齿闪烁元素没有独立的合成层圆环加will-change: transform卡片加translateZ(0)5.2 性能与兼容性性能上最该注意的其实只有一条只让 transform 和 opacity 参与动画。我在早期版本里为了做高亮把当前卡片的box-shadow也用 transition 动了起来结果在低端安卓上滚动一圈掉到四十多帧。后来把阴影变化改成用一层伪元素的opacity来淡入淡出帧率立刻回到六十。图片解码也是个大坑。八张卡片如果都是 2000px 宽的大图首次进入页面时浏览器要同时解码八张第一帧会明显卡顿。我的做法是卡片里用loadinglazy延迟非首屏图片同时在render()里对即将转到正面的卡片提前一点做预热把它的img.src赋值到一个隐藏的Image对象里。实测首次进入的卡顿感基本消失了。兼容性方面transform-style: preserve-3d和backface-visibility现在的支持度都很好。真正需要留意的是 Safari——它对preserve-3d的渲染一直比较保守有时候父层的圆角或阴影会让 3D 效果失效。遇到这种情况把圆角从圆环转移到卡片上一般就能绕过。5.3 我踩过的几个坑第一个坑是给圆环加动画时的帧率问题。我一开始偷懒写了两组keyframes做自动播放让它和 JS 控制的状态混着用结果手动点击和自动播放经常打架圆环会先按 JS 转到一半再被关键帧拽回去。后来全部改成 JS 驱动只保留transition做插值瞬间就顺了。结论这种有状态的交互组件动画的“决策权”只能有一处。第二个坑是卡片内边距和半径计算没对齐。我一开始给卡片加了padding但算半径时用的是ring.offsetWidth那个宽度含 padding算出来的半径偏大卡片之间缝隙特别宽。后来干脆把 padding 全部移到卡片内部的内容层上卡片本身保持零内边距尺寸就完全可控了。第三个坑是触屏上的 pointer 事件和 click 冲突。拖拽结束后浏览器还会补发一次click如果卡片上挂了跳转链接用户拖完松手就会莫名其妙地跳走。解决办法是在pointerup里判断一下总位移超过 8px 就设一个标记在click处理函数里检查这个标记并拦截掉。第四个坑是深色背景下的边缘描边。卡片是圆角的旋转到侧面时会和背景发生混色边缘出现一圈淡淡的灰边。我最后是在卡片上加了一层inset的box-shadow做描边颜色取比卡片底色略亮一点的值视觉上就干净了。5.4 想继续扩展可以往哪走这套骨架的可扩展性其实挺强的。最省事的一个加法是“点击侧边卡片直接转到正面”——在卡片上绑点击事件把index设成那张卡片的序号就行因为圆环会自动找最短路径吗其实不会它会按你给的角度转过去所以如果你希望它“就近转”得自己算一下当前角度和目标角度的差值如果超过 180° 就选择反向转这样看起来更自然。第二个加法是给每张卡片加独立的入场动画。比如当前卡片被激活时它的标题从下方滑入、图片轻微放大。做法是在.card.is-active .card-meta上写 transition切换 class 时自动触发。注意这些动画只作用在卡片内部不要影响圆环的 transform不然会再次和旋转逻辑打架。第三个加法是把卡片数量、内容改成从接口或 JSON 动态渲染。这时候记得在渲染完成后重新调用一次layout()和render(false)并且重新绑定ResizeObserver否则半径还是按旧的卡片数量算的。6. 收个尾这个旋转木马轮播图我从最初的想法到最终能用前后改了三版真正花时间的不是写代码而是调那几个看不见的数——半径 434 还是 460、缓动曲线用哪条、拖拽阈值设 100 还是 120。这些值没有标准答案但在自己的项目里试出最舒服的那个之后整套东西的质感会明显不一样。如果你准备动手我的建议是先把卡片数量定下来再拿公式算出半径把静态的圆环先摆好、截图看一眼分布对不对确认没问题了再去写旋转和交互。反过来先写 JS 再调布局会一直在“转了但没转对”的状态里打转。另外backface-visibility: hidden和transform-style: preserve-3d这两个属性建议一开始就写上去别等到发现画面脏了再回头找——十有八九你会先怀疑自己算错角度而不是怀疑少了一行 CSS。
返回列表