ByteNoteByteNote
手搓随机决策转盘:CSS 的坑与三条正路
字

字节笔记本

2026年10月7日 · 约 8 分钟读完

手搓随机决策转盘:CSS 的坑与三条正路

API中转
¥120

选择困难症发作的时候,最实用的工具往往最原始:一个转盘。把「中午吃什么」「先做哪个需求」写进八个扇区,指针一停,事情就算落定。转盘大概是最古老的随机化装置之一,从街头幸运大转盘到电商大促的抽奖页,再到团建抽签、课堂随机点名,它始终没有退出历史舞台——规则透明、结果直观、仪式感强。这个需求也小到不像一个工程问题:一个圆形、几段颜色、一次旋转。但真要用 CSS 从零手搓一个,会撞上前端图形领域几个相当经典的坑:扇形怎么画、绕哪个点转、转完之后怎么从角度反推结果。本文以一次完整的踩坑过程为线索,把这三件事讲透,最后给出三条更稳的实现路线。

一、最直觉的方案:clip-path 拼扇形

最容易想到的写法是纯 CSS:容器裁成正圆,里面放八个 div,各自 transform: rotate(45deg) 错开角度,再用 clip-path: polygon(0 0, 100% 0, 50% 100%) 把每个 div 切成三角形。零依赖、纯声明式,乍看能跑。

但它有两个先天缺陷。第一,polygon 只能切直边多边形,而真实转盘的扇形是两条半径加一段圆弧;扇区只有四个时弧和弦的差别还能糊弄过去,一旦切到八个以上,每个扇区边缘的「缺口」就肉眼可见。第二,更隐蔽:如果只写 position: absolute 而不给 top/left,绝对定位元素会停留在它的「静态位置」——八个块级 div 在文档流里本该竖着排,于是各自带着不同的纵向偏移,再绕自身中心旋转,整个盘直接散架。很多「转盘显示乱掉」的问题,根子就在这句没写完整的位置声明上。

二、旋转中心与落点判定,都比看上去讲究

修好堆叠只是第一步,真正决定「转盘像不像转盘」的,是旋转中心和结果计算。

旋转中心。 扇形必须绕转盘圆心转,而不是绕自身盒子的几何中心。transform-origin 默认取盒心,直接 rotate 就会出现「扇区自转却不在盘上」的画面。正确做法有两种:把每个扇形的锚点移到圆心、transform-origin: 0 0 再旋转;或者更简单——只旋转整个转盘容器一次。让八个扇区各自挂 transition 不仅浪费合成层,低端设备上还可能彼此不同步。

落点判定。 一个常见错误是把「转盘累计转了多少度」直接当结果索引,比如 Math.floor(randomAngle / 45) + 1。这个式子有两个洞:随机角度到了几千的量级,索引直接越界,因为没对 360 取模;更要紧的是概念错了——结果取决于哪个扇区停在指针下方,和转盘总共转了几圈无关。指针放在正上方时,正确的判定式是:

js
const sector = Math.floor(((360 - finalAngle % 360) % 360) / sectorAngle);

先取最终角度对 360 的余数,再换算成它相对指针的补角,最后除以单个扇区的角度,才是真正的落点。

spin 结果判定:取模、补角、扇区角

动画手感。 cubic-bezier(0.25, 0.1, 0.25, 1) 这类先快后缓的曲线模拟了真实转盘的摩擦衰减;总角度至少给足五整圈加随机零头(360 * 5 + random * 360),才有「抽一把」的仪式感;动画期间用一个 isSpinning 布尔锁防连点,结束后再通过 setTimeout 或 transitionend 揭晓结果。

三、响应式的坑:圆不能被拉成椭圆

移动端是这类图形组件的第二重考验。常见翻车写法是 width: 90vw; max-width: 400px; height: 90vw; max-height: 400px——看起来对称自洽,实际上宽和高各自独立求值,一旦某个方向触到上限而另一个没有,正圆就变椭圆,扇形随之错位。

更稳的姿势有三条:容器用 width: min(90vw, 400px) 配合 aspect-ratio: 1,一个属性锁死宽高比,怎么缩都是正圆;内部尺寸一律用百分比或相对单位,别在扇区、中心圆盘、字号里夹写死的 px;别忘了 viewport meta,缺了它手机会按 980px 的桌面宽度布局再整体缩小,字小、点击偏,是「手机上显示乱」的另一大元凶。

调完别只看一种机型。转盘是典型的比例敏感组件,图标和文字的位置全部依赖容器的几何中心,窄屏和宽屏各看一眼,比在开发者工具里拖十次窗口都管用。如果做的是可嵌入组件,再补一层容器查询(container queries),让转盘跟随父容器而非视口缩放,侧栏、弹窗等场景就都能直接复用。

四、三条更稳的路线

决策转盘的三条实现路线与选型建议

路线一:conic-gradient。 一行 CSS 画出全部扇区:

css
background: conic-gradient(
  #ff6b6b 0 45deg, #feca57 45deg 90deg,
  #1dd1a1 90deg 135deg /* ... */
);

无子元素、天然无缝、随便缩放。缺点是没法在扇区内部排文字,标签只能绝对定位叠上去,适合扇区固定、文案简单的演示页。

路线二:SVG。 用 path 的 A(arc)命令画真正的圆弧扇形:M 圆心 L 起点 A r r 0 0 1 终点 Z,生成脚本只需十几行三角函数。SVG 的杀手锏是 textPath 能让文字沿弧线排布,这是抽奖转盘类产品几乎清一色选它的原因;矢量格式也让它在任何分辨率下都不糊。

路线三:现成库。 React 生态的 react-custom-roulette 这类组件,传入 data 和 prizeNumber,在 onStopSpinning 回调里收结果,指针对齐、惯性缓动、文字排布全部包办。产品化、赶工期、要可访问性,直接上库不丢人。

进阶:不等概率的扇区。 转盘还有一个常被忽略的灵活性:扇区不必等分。把每个选项的权重按比例换算成角度——权重 2 的选项占 90 度,权重 1 占 45 度——conic-gradient 和 SVG 都天然支持这种画法;落点判定也只需把「角度除以固定扇区角」换成「在累加角度表里查找」。顺带说清一个边界:自己用它决定吃什么,等分即可;做营销抽奖时「人人都有奖」的转盘几乎都是加权的,技术上一张角度表就够,但中奖概率应当如实告知用户。

选型一句话:一次性演示用 conic-gradient,要贴字、要品牌视觉用 SVG,要产品级动画细节用库。自研的价值从来不是省一个依赖——旋转中心和落点判定这两个问题,换任何方案都躲不开,库只是把它们封装了,并没有替你理解。

收尾:小工具里的通用三问

回看这次踩坑,所有问题都落在三个朴素的问题上:我在画的到底是什么几何图形(多边形还是圆弧)?坐标系的原点在哪(旋转中心)?动画结束的瞬间,怎么从视觉状态反推回数据(落点判定)?转盘只是这三问最直观的载体,做图表、写可视化、开发小游戏时,它们会换一副面目反复出现。

最后三个实用提醒。其一,Math.random() 是伪随机,帮自己选餐厅足够了;若转盘用于营销抽奖、真发奖品,请改用服务端开奖加密码学随机,否则结果可被预测、可被脚本刷。其二,旋转动画对部分用户是负担,尊重系统的 prefers-reduced-motion 设置——用户选择减弱动效时就跳过旋转、直接展示结果,并用 aria-live 把结果播报给读屏用户。其三,决策转盘的真正作用不是替你决策,而是把犹豫从脑子里外化到盘面上——指针落下那一刻,你心里的失落或窃喜,往往比转盘本身更诚实。

相关文章

分享: