news 2026/9/30 4:26:54

手写 3D 旋转木马轮播:CSS3 3D 变换、拖拽惯性与自动播放

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写 3D 旋转木马轮播:CSS3 3D 变换、拖拽惯性与自动播放

做产品落地页的时候,客户丢过来一张参考图,是那种能绕着圈转的卡片墙,鼠标往左一拖整个环跟着转,松手还带一点惯性,转到正前方的那张自动放大高亮。我第一反应是搜现成的轮播插件,翻了好几个库之后发现,能做这种旋转木马效果的要么体积大得离谱,要么参数暴露得一塌糊涂,改个样式要翻半天源码。后来干脆用 html+css+js 从零撸了一个,两个晚上的时间,核心代码不到两百行,可控性强得多。

这篇就把这套旋转木马轮播图的完整实现拆开讲。它适合三类人:刚学完 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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>3D 旋转木马轮播</title> </head> <body> <!-- 第一层:交互容器,负责接事件、控制透视 --> <div class="carousel" id="carousel"> <!-- 第二层:舞台,只负责整体旋转 --> <div class="stage" id="stage"> <!-- 第三层:卡片,各管各的角度和半径 --> <div class="card"><img src="1.jpg" alt=""><span class="cap">第一张</span></div> <div class="card"><img src="2.jpg" alt=""><span class="cap">第二张</span></div> <div class="card"><img src="3.jpg" alt=""><span class="cap">第三张</span></div> <div class="card"><img src="4.jpg" alt=""><span class="cap">第四张</span></div> <div class="card"><img src="5.jpg" alt=""><span class="cap">第五张</span></div> <div class="card"><img src="6.jpg" alt=""><span class="cap">第六张</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.3827,R = 120 / 0.3827 ≈ 313px。

但按这个公式算出来的半径,实际效果会挤得有点紧,卡片边缘几乎贴在一起,转动的时候视觉上分不开。因为公式算的是"刚好相切",而人眼需要间隙来区分前后关系。所以实际操作中我会乘一个间隙系数:

卡片数量公式半径(w=240)建议系数实际取值
41701.25212
62401.20288
83131.15360
103881.10427
124641.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]这个小范围里。这是长时间运行的页面里必须做的维护,不然跑几个小时之后可能会看到莫名其妙的抖动。

自动播放就是定时器推动 position:

let 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(); });

键盘支持是很多人会忽略的。给容器加tabindex="0"和左右方向键监听,能覆盖一批不用鼠标的用户。另外prefers-reduced-motion媒体查询也该处理一下——有些用户对动效敏感,系统里开了"减少动态效果",这时候应该把过渡时长砍到接近 0,而不是照样转:

@media (prefers-reduced-motion: reduce) { .stage, .card { transition-duration: .01ms !important; } }

7.3 还能往下做的几件事

如果项目还有余量,有几个方向可以继续加:卡片内容用img的loading="lazy"配合预加载前两张,避免转过去的时候白屏;给卡片加一点随角度变化的rotateX倾斜,做出轻微的"内凹"感;或者把整个环做成双排,前后两圈反向旋转,视觉复杂度会高不少但代码改动不大,只是把卡片按奇偶分成两组,分别设不同的半径和旋转方向。

还有就是卡片数量动态化的场景,如果是从接口拿数据再渲染,记得渲染完之后重新读取COUNT和STEP,别用初始化时缓存的值。我有个项目就是接口返回数量变了但角度没重算,卡片全挤在一块,排查了半天才发现是缓存了旧的常量。

这套代码我后来在三个不同项目里复用,改动量最大的一个也就换了半径系数和卡片尺寸,核心逻辑一行没动。这也是我越来越喜欢"JS 只写变量、CSS 负责渲染"这个模式的原因——边界清楚的东西,复用起来才不心虚。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:26:19

华为交换机实战配置:Console初始化、Telnet/Web开通与故障排查

简介&#xff1a;本资源是一份面向网络工程师、运维人员及华为认证备考者的实操型配置指南&#xff0c;聚焦交换机远程管理核心能力训练&#xff0c;系统解决TELNET多模式认证与访问控制配置难题。文档以华为S3100/S5100/S3600/S5600系列交换机为实操平台&#xff0c;完整覆盖账…

作者头像 李华
网站建设 2026/9/30 4:25:23

事后经验回放HER:解决稀疏奖励与多目标任务难训练的强化学习利器

看到“hindsight”这个词&#xff0c;我先想到的是认知心理学里那个经典概念——后见之明偏差&#xff0c;也就是我们常说的“事后诸葛亮”。但在强化学习圈&#xff0c;这个词还有另一个让我条件反射般兴奋的对应&#xff1a;Hindsight Experience Replay&#xff0c;事后经验…

作者头像 李华
网站建设 2026/9/30 4:24:32

Univer 在线表格引擎实战:Canvas 渲染与 Facade API 集成指南

1. Univer 到底是个什么东西&#xff0c;为什么值得单独拿出来聊第一次听到 Univer 这个名字&#xff0c;很多人会以为是某个新出的前端框架或者 UI 组件库。其实不是。Univer 是一套开源的在线电子表格与文档协作引擎&#xff0c;核心定位是让开发者能把“类 Excel”“类 Goog…

作者头像 李华
网站建设 2026/9/30 4:23:15

PHP+uniapp实战:本地生活服务平台开发全流程与踩坑记录

最近在做一个本地生活类的信息服务平台&#xff0c;技术选型是php uniapp&#xff0c;面向城市商铺分类信息、活动发布与展示这类场景&#xff0c;最终产物要覆盖微信小程序和移动端 App。这个组合乍一看不算新潮&#xff0c;但跑完整个开发、打包、上架、适配流程之后&#x…

作者头像 李华
网站建设 2026/9/30 4:22:38

Redis安装部署指南:Windows与Linux从零到生产级配置

1. 写在安装之前做后端开发的&#xff0c;只要项目里用到缓存、session共享、排行榜或者消息队列&#xff0c;Redis基本是绕不开的那一个。不管你用的是Spring Boot、Express还是Gin&#xff0c;Redis装不好&#xff0c;后面写再多代码也跑不起来。这篇文章要解决的就是这个问题…

作者头像 李华