上个月接了一个看起来人畜无害的需求:让 6 个快捷操作按钮围绕中心按钮排成圆弧,点中心按钮时铺开,再点一下收回。我最初的想法是布局用 flex 随便排排,再给按钮加一点 rotate 旋转变换糊弄过去,但当我把第三个按钮怼上去之后,发现弧线的起点、间隔、朝向和点击热区全都对不上,才意识到这不是"排几个圆"的问题,而是"把一个圆分成几份再定位"的问题。把极坐标、CSS 变换和指针事件串起来之后,我做成了一个配置数组驱动的圆弧按钮布局组件,今天把整个过程完整复盘一遍。内容适合要做圆形菜单、扇形工具盘、游戏技能环、仪表盘快捷入口这类界面的前端同学,也适合想把常见交互数学底子补扎实的初学者。
1. 先搞明白:圆弧按钮布局在哪些场景才是真需求
1.1 拿到需求时的第一反应,为什么大多不对
先别急着写代码,第一步是想清楚这个布局真正解决的问题。大多数人的第一反应和我一样,想用 flex 解决。flex 确实能快速把按钮排成一行或者一列,但它的核心是"主轴 + 交叉轴方向上的伸缩与对齐",它不知道"角度"是什么概念。让 6 个按钮均匀绕一圈,相当于把 360° 切成 6 份,每份 60°,然后在每个角度上摆一个按钮,这是典型的几何计算,而不是单纯的布局引擎能直接给出的能力。
还有人会说,用transform: rotate不也能绕出来吗?比如每个按钮 rotate 60°,再 translateX 一段距离,看起来确实像围了一个圈。这个思路其实触及了正解的一部分,但只做 rotate 会立刻踩中三个问题:一是文字和图标会跟着整体旋转,按钮的文案歪着放,用户根本读不清;二是 rotate 的默认旋转中心是元素自身,不是我们想要的圆心,你得额外去调transform-origin;三是多个按钮之间的间隔完全没有精确保证,全靠拍脑袋试数字。所以最终还是要回到"每个按钮应该落在什么坐标上"这件事,也就是几何层面。
1.2 JS + DOM 相比 CSS / SVG / Canvas 的选型结论
我见过不少类似的讨论,都说"这个用 CSS 也能做""用 SVG 更优雅""Canvas 性能吊打 DOM",这些说法都对,但要看场景。圆弧按钮布局本质上是"带交互的控件组",不是纯图形绘制。我最后选了 JS + DOM,理由很直接:它能把位置计算、动画过渡、事件绑定、焦点管理都放在一个相对简单的模型里,同时兼容性最好。
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| CSS 三角函数 | 简单、不依赖 JS | 动态数量和复杂交互耦合时代码会绕;需要不断生成类名或结构 | 静态装饰性弧线、纯展示 |
| SVG | 天然支持路径和坐标 | 内部放 HTML 按钮麻烦,事件绑定绕一圈 | 数据可视化中的弧线本身 |
| Canvas | 性能上限高、动画自由 | 可访问性差,按钮点击、Tab 焦点、屏幕阅读器都难处理 | 游戏、粒子类图表 |
| JS + DOM | 交互灵活、可访问性好、组件好复用 | 布局计算要自己写,按钮数量极大时 DOM 节点太多 | 大多数业务里的圆形菜单、工具盘 |
如果你的按钮数量在十几个以内,JS + DOM 是最稳的平衡点。按钮超过一二十个,或者要做大量实时粒子特效,那时候再把 Canvas 拿出来也不迟。
2. 三个核心公式,把"沿弧线排"翻译成坐标
2.1 极坐标到直角坐标:雷达定位的思路
雷达上定位一个目标,先报一个方位角,再报一个距离,这样就足够在地图上画出它的位置。屏幕上的圆弧按钮布局也是同一个逻辑:每个按钮相对于圆心有一个半径r,也就是它离中心按钮的距离;再有一个角度θ,决定它在圆的哪个方向。最后通过下面两个式子把极坐标翻译成屏幕坐标:
x = r * cos(θ) y = r * sin(θ)如果要基于某个具体的容器,就再加上圆心坐标:
x = centerX + r * cos(θ) y = centerY + r * sin(θ)对应到 JavaScript 就是一个三行的函数:
function polarToCartesian(radius, angleInRadians) { return { x: radius * Math.cos(angleInRadians), y: radius * Math.sin(angleInRadians) }; }这里要特别提醒一个和数学课不一样的地方:CSS 里 y 坐标向下为正,所以同样的公式放在屏幕上,角度的旋转方向是顺时针的。θ = 0时cos(0) = 1、sin(0) = 0,对应正右方向;θ = Math.PI / 2时对应正下方向。想从最顶部开始排按钮,起始角要写成-Math.PI / 2。这个细节很多人第一次写的时候都会栽跟头,我也栽过。
| 角度(弧度) | cos 值 | sin 值 | 按钮所在位置 |
|---|---|---|---|
| -π/2 | 0 | -1 | 正上方 |
| 0 | 1 | 0 | 正右方 |
| π/2 | 0 | 1 | 正下方 |
| π | -1 | 0 | 正左方 |
2.2 起始角度和步长该怎么定
有了坐标转换公式,接下来要解决第二个问题:每个按钮的θ怎么算。先定一个起始角,然后按顺序累加相等的步长,这就是"把总角度均匀切开"的思路。
最常见的需求是排满一整圈。假设按钮总数是count,那么每个按钮之间的角度间隔就是2π / count,第i个按钮的角度就是:
const step = Math.PI * 2 / count; const angle = -Math.PI / 2 + step * i;这里的-Math.PI / 2就是让第 0 个按钮从正上方开始。如果产品想要的是一个半圆或者 3/4 圆,只要把总弧度换成对应的值就行:半圆是Math.PI,270° 是Math.PI * 1.5。如果首尾两端还要给按钮本身留出间隙,比如按钮要贴着弧线两端向内偏移半个按钮宽度,那步长可以改成span / (count - 1),让第一个和最后一个按钮正好卡在弧线两端。
我在实际项目中习惯把它做成配置,方便随时调整:
const config = { items: ['A', 'B', 'C', 'D', 'E', 'F'], radius: 150, startAngle: -Math.PI / 2, arcSpan: Math.PI * 2 };然后在一个循环里生成所有按钮的坐标。这样按钮数量从 6 改成 8,或者从一个整圆改成半圆,都只需要改配置,不用动核心计算逻辑。
2.3 真正落地时用 transform 还是 left / top
同样是把按钮放到某个坐标上,用left/top定位可以,用transform: translate也可以,但性能不是一个量级。left/top变化会触发布局计算,按钮移动几十个像素浏览器就要重新计算整棵 DOM 树的位置;transform只在合成阶段处理,不会触发 reflow,拖拽旋转圆环的时候 60fps 通常没什么压力。
另一个原因是展开动画。按钮要先集中在圆心,再弹到圆周上。如果坐标用left/top表示,动画从中心到终点会要求浏览器在布局阶段连续插值,掉帧不说,代码也更绕。所以我最终把定位拆成两步:按钮初始位置用left: 50%; top: 50%;加负 margin 把按钮中心压到圆心,真正的圆弧偏移交给transform: translate(var(--x), var(--y))。
--x和--y是 CSS 自定义属性,由 JS 注入。这套做法的好处是位置计算和动画样式完全解耦:JS 只负责把"第几个按钮的角度、半径"换算成坐标,CSS 只负责过渡和动画。之后你想改动画曲线、改 easing,都不用碰坐标计算代码。
3. 手写一个圆形展开菜单:完整可跑通的 Demo
3.1 骨架搭建:一个中心触发按钮加一组子按钮
我先搭一个最基本的 DOM 结构。.radial-menu是一个正方形容器,里面放一个中心触发按钮.menu-trigger和一个承载所有子按钮的.menu-items层。子按钮由 JS 动态生成,这里先留空。
<div class="radial-menu" id="radialMenu"> <button class="menu-trigger" id="menuTrigger" type="button" aria-expanded="false" aria-label="展开菜单">+</button> <div class="menu-items" id="menuItems"></div> </div>对应的基础样式如下。注意.menu-item我先用left: 50%; top: 50%;加上负 margin 让按钮中心对齐容器中心,后续真正的位置偏移完全靠transform里的--x和--y:
.radial-menu { position: relative; width: 480px; height: 480px; margin: 40px auto; border-radius: 50%; background: radial-gradient(circle, #f5f7fa 0%, #e6ecf2 100%); user-select: none; touch-action: none; } .menu-trigger { position: absolute; left: 50%; top: 50%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 50%; border: none; background: #2563eb; color: #fff; font-size: 28px; cursor: pointer; z-index: 10; box-shadow: 0 8px 20px rgba(37, 99, 235, .35); transition: transform .3s ease, background-color .3s ease; } .menu-trigger.is-open { transform: rotate(45deg); background: #dc2626; } .menu-items { position: absolute; inset: 0; } .menu-item { position: absolute; left: 50%; top: 50%; display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; margin: -28px 0 0 -28px; border-radius: 16px; background: #fff; color: #1f2937; font-size: 16px; font-weight: 600; box-shadow: 0 6px 16px rgba(0, 0, 0, .12); cursor: pointer; opacity: 0; pointer-events: none; transform: scale(.2); transition: transform .5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity .25s ease; } .menu-item.is-open { opacity: 1; pointer-events: auto; transform: translate(var(--x), var(--y)) scale(1); } .menu-item__inner { display: block; transform: rotate(calc(0deg - var(--menu-rotate, 0deg))); }细心的读者会看到我已经预留了一个.menu-item__inner,这是后面拖拽旋转时给文字做反向补偿用的。现在--menu-rotate还没定义,默认是 0deg,内层文字不会受影响。
3.2 用 JS 算坐标并注入 CSS 变量
接下来是核心的生成逻辑。我遍历配置里的 items 数组,对每个按钮计算角度,再用polarToCartesian算出相对圆心的偏移量,最后通过style.setProperty('--x', ...)把坐标注入到元素上。这里用的是相对坐标,偏移量的原点就是按钮初始位置所在的圆心。
const menuItemsWrap = document.getElementById('menuItems'); const trigger = document.getElementById('menuTrigger'); const menuItemEls = []; const config = { items: ['A', 'B', 'C', 'D', 'E', 'F'], radius: 150, startAngle: -Math.PI / 2, arcSpan: Math.PI * 2 }; function polarToCartesian(radius, angleInRadians) { return { x: radius * Math.cos(angleInRadians), y: radius * Math.sin(angleInRadians) }; } function createMenuItems() { const fragments = document.createDocumentFragment(); const count = config.items.length; const step = count > 1 ? config.arcSpan / count : 0; config.items.forEach((label, index) => { const angle = config.startAngle + step * index; const pos = polarToCartesian(config.radius, angle); const el = document.createElement('div'); el.className = 'menu-item'; el.innerHTML = `<span class="menu-item__inner">${label}</span>`; el.dataset.index = index; el.style.setProperty('--x', `${pos.x}px`); el.style.setProperty('--y', `${pos.y}px`); fragments.appendChild(el); menuItemEls.push(el); }); menuItemsWrap.appendChild(fragments); }这里很多实现细节值得解释一下。count取自config.items.length,所以按钮数量是完全数据驱动的。pos.x和pos.y是相对于圆心的偏移,不是绝对坐标,因为按钮的初始位置已经用left: 50%; top: 50%;放在圆心了,translate只是基于当前中心往目标方向移动一段距离。半径我选了 150px,按钮直径 56px,6 个按钮均匀分布时,相邻按钮之间的弧长大约是2π × 150 / 6 ≈ 157px,扣掉按钮直径还有 100px 左右的视觉间隙,圆环看起来疏密合适。
3.3 展开收起动画与错峰延迟
展开的逻辑也很直接:给每个子按钮加上.is-open类,利用 CSS 里定义好的过渡效果,按钮会从中心缩放 + 平移"弹"到目标坐标。为了让动画不显得生硬,我给每个子按钮设置了依次递增的transitionDelay,形成一个像波浪一样往外推的感觉。
function openMenu() { menuItemEls.forEach((el, i) => { el.style.transitionDelay = `${i * 0.045}s`; el.classList.add('is-open'); }); trigger.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); } function closeMenu() { const total = menuItemEls.length; menuItemEls.forEach((el, i) => { el.style.transitionDelay = `${(total - 1 - i) * 0.045}s`; el.classList.remove('is-open'); }); trigger.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); } trigger.addEventListener('click', () => { const expanded = trigger.getAttribute('aria-expanded') === 'true'; expanded ? closeMenu() : openMenu(); }); createMenuItems();收起时的transitionDelay我特意做成了反序:展开时第 0 个按钮先动,后面的依次跟上;收起时如果还用同一个顺序,会出现最外面一排先消失,视觉上有一种"往回卷"的不协调感。反过来让外圈的先收、靠近中心的后收,整个动画就像一面帘子从边缘往里关,顺眼很多。
动画曲线里比较关键的是cubic-bezier(0.34, 1.56, 0.64, 1)。中间那个1.56已经超过 1 了,会让按钮在落位时产生一点点过冲再回弹,看起来更像是物理上的"弹到位置",而不是匀速滑动。透明度的过渡我控制在 0.25s,比位移动画短,因为透明度如果太慢,按钮已经到了坐标但还半透明,观感很怪。
4. 再加一层交互:拖拽旋转整个圆环
4.1 鼠标 / 手指角度计算与旋转状态管理
静态的圆形菜单已经能用了,但只做展开收起还差一点意思。后来我把整组按钮又加上了拖拽旋转:用户按住圆环空白区域左右滑动,整个按钮组绕着圆心转,像一个可以拨动的圆盘。
拖拽旋转的本质,是把鼠标到圆心的方向变化映射成整个圆环的旋转角度。Math.atan2(dy, dx)是标准的求夹角 API,第一个参数传 y 差值,第二个传 x 差值,返回弧度。这里有个经典坑:参数顺序是先 y 后 x,写反了角度会直接错 90°。拖拽开始时记录起始角度和当前的baseRotate,拖拽过程中不断计算新角度和起始角的差,加到baseRotate上。
let currentRotate = 0; let baseRotate = 0; let startPointerAngle = 0; let dragging = false; function pointerAngle(event) { const rect = menuItemsWrap.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; return Math.atan2(event.clientY - cy, event.clientX - cx); } function applyRotate() { menuItemsWrap.style.transform = `rotate(${currentRotate}deg)`; menuItemsWrap.style.setProperty('--menu-rotate', `${currentRotate}deg`); } menuItemsWrap.addEventListener('pointerdown', (e) => { dragging = true; startPointerAngle = pointerAngle(e); baseRotate = currentRotate; menuItemsWrap.setPointerCapture(e.pointerId); }); menuItemsWrap.addEventListener('pointermove', (e) => { if (!dragging) return; let delta = pointerAngle(e) - startPointerAngle; delta = normalizeAngleDiff(delta); currentRotate = baseRotate + delta * 180 / Math.PI; applyRotate(); }); menuItemsWrap.addEventListener('pointerup', () => { dragging = false; });我用的是 Pointer Events,一套事件同时覆盖鼠标和触摸屏,省去分别绑 mouse 和 touch 的重复代码。.menu-items这一层的pointerdown不会和中心按钮冲突,因为中心按钮在它之上,点击中心按钮时事件不会冒泡到这里。
4.2 过零点和惯性滚动的处理
你如果直接跑上面的代码,很快会发现一个问题:鼠标从圆环 12 点方向左边拖到右边,圆环可能会突然朝反方向猛转一大圈。原因是Math.atan2的返回值范围是[-π, π],当鼠标角度从接近 π 跨到接近 -π 时,直接相减得到的差值接近-2π,于是被当成"反向转了一圈"。
解决办法是归一化:只要差值超过 π,就加或减2π,把它拉回[-π, π]区间。这样可以保证任何一次角度变化,都按最近的方向去走,而不是绕远路。
function normalizeAngleDiff(diff) { while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; return diff; }拖拽做完之后,我顺手加了一个惯性滚动,让圆环松手后可以继续滑动一小段再减速停下来。思路也不复杂:在pointermove里记录最近几次的采样时间和角度,pointerup时用角度差除以时间差得到一个角速度,然后通过requestAnimationFrame逐帧衰减这个角速度并更新旋转角度。
let samples = []; let velocity = 0; function onPointerMove(e) { const now = performance.now(); const angle = pointerAngle(e); samples.push({ t: now, a: angle }); if (samples.length > 5) samples.shift(); // 这里同步更新 currentRotate 和 applyRotate ... } function onPointerUp() { dragging = false; if (samples.length >= 2) { const dt = (samples[samples.length - 1].t - samples[0].t) / 1000; const da = samples[samples.length - 1].a - samples[0].a; velocity = da / dt; } samples = []; if (Math.abs(velocity) < 0.01) return; const tick = () => { currentRotate += velocity * (16 / 1000) * (180 / Math.PI); velocity *= 0.94; applyRotate(); if (Math.abs(velocity) > 0.01) { requestAnimationFrame(tick); } }; requestAnimationFrame(tick); }0.94是每帧的衰减系数,数值越小减速越快。如果你想要更跟手的阻尼感,可以调到0.96左右,体验会柔和不少。这里要注意每次重新拖拽前把上一轮的动画帧取消掉,不然拖拽过程中惯性动画还在跑,两个逻辑会打架。
4.3 文字朝向的修正技巧
圆环整体旋转后,按钮里的文字也会跟着斜掉。如果按钮里是纯图标,这个问题不明显;如果是文字,比如快捷操作名、数字编号,看起来就会很别扭。
最省事的做法,是给按钮里的文字再包一层.menu-item__inner,在文字节点上做一次反向旋转,和父容器转过的角度刚好抵消。因为旋转发生在文字自身的坐标系里,不会影响按钮已经算好的坐标位置。
.menu-item__inner { display: block; transform: rotate(calc(0deg - var(--menu-rotate, 0deg))); }--menu-rotate我在applyRotate里同步更新到了.menu-items上,所以子节点能直接通过 CSS 变量读到当前的旋转角度。calc(0deg - var(--menu-rotate, 0deg))的意思就是把角度取负,这就是文字保持正读的关键。这里我没有用var(--menu-rotate) * -1这种写法,因为calc里的乘法兼容性没有加减法好,0deg - var(--menu-rotate)在实践里最稳。
5. 改进期间遇到的坑和落地建议
5.1 坐标与 transform 的四个坑
这套组件从原型到落地,我踩过的坑不少,最典型的有四个,列出来给你避雷。
第一个是 y 轴方向问题。CSS 坐标系里 y 向下为正,所以角度从-π/2开始才是正上方,从 0 开始是正右方。如果你按数学课的坐标系习惯去排,排出来的圆环是上下翻转的。这个坑一次就能记住,因为错得非常明显。
第二个是按钮中心对齐问题。很多人用left: 50%; top: 50%之后发现按钮虽然到了容器中心,但总是偏了半个身位,然后开始在坐标里硬加一个itemSize / 2。其实直接用负 margin 把按钮自身尺寸的一半补回去就行,不要往坐标公式里塞修正值,否则逻辑一复杂就全乱了。
第三个是 transform 函数顺序问题。同一个元素上同时有translate和rotate时,先后顺序会影响最终位置和朝向。如果你搞不清楚矩阵乘法,最简单的办法就是像上面那样分层:按钮负责translate定位,文字内层负责rotate补偿,每个元素只干一件事,就不会打架。
第四个是 transitionDelay 没有反序的问题。展开和收起如果沿用同一个延迟顺序,收起动画看起来是"倒着回卷"的,观感很怪。这个在第 3 章已经讲过,实际交互里很容易被忽略,建议先收藏这个细节。
5.2 响应式与按钮数量变化时的参数调整
圆弧布局最大的优势就是数据驱动。按钮数量从 6 改成 8,只要改config.items,步长会自动计算,坐标也会重新生成。但有一个隐藏问题:按钮数量变多之后,如果半径不变,相邻按钮会越来越挤甚至重叠。
可以用一个简单的周长估算公式做校验:2π × radius ≥ count × itemSize。也就是说,所有按钮直径加起来不能超过内圈周长。实际保留余量的话,我会让radius再放大 15% 左右,视觉上更舒展。
容器尺寸变化时,用 ResizeObserver 监听并重新计算半径,这是目前比较稳的方案:
const ro = new ResizeObserver(() => { const rect = menuItemsWrap.getBoundingClientRect(); config.radius = Math.min(rect.width, rect.height) / 2 - config.itemSize / 2 - 16; updatePositions(); }); ro.observe(menuItemsWrap);updatePositions其实就是把createMenuItems里的坐标计算抽出来重跑一遍,重新setProperty每个按钮的--x和--y。因为动画用的是 CSS 变量,坐标更新后按钮会自动跑到新位置,你甚至不需要额外写过渡逻辑。
5.3 可访问性和移动端细节
这部分的坑更多是"看不见的"。收起状态下,子按钮虽然opacity: 0且pointer-events: none,但在 DOM 里依然是可聚焦的,键盘用户按 Tab 很有可能会跳到隐藏按钮上,体验非常割裂。所以我打开菜单时会给按钮设置tabIndex = 0和aria-hidden="false",收起时设置tabIndex = -1和aria-hidden="true",同时保持trigger上的aria-expanded和实际状态同步。
移动端拖拽旋转还有个关键点:一定要在容器上写touch-action: none。否则手指按住圆环拖动时,浏览器会默认把这次手势当成页面滚动,拖拽逻辑被系统中断,圆环转起来一顿一顿的。如果做了惯性滚动,松手后要记得取消上一轮的requestAnimationFrame,不然连续快速拖动几次之后,圆环会自己乱转。最后给按钮区域加上user-select: none,防止连续拖拽时选中页面上的文字,这个在桌面端尤其明显。
做完以上这些,一个从静态定位到动态交互都比较完整的圆弧按钮布局组件基本就成型了。最后分享一个我自己调试时养成的习惯:把--x和--y用 CSS 变量注入之后,排查坐标问题变得特别方便。动画跑偏时,直接在 DevTools 里选中对应按钮,看一眼 Computed 里的--x/--y数值,手动改一改就能验证是计算问题还是动画问题。这种把坐标计算、动画样式、数据配置三者分离的做法,很适合圆弧布局这类几何组件,我已经把它复用到两个不同的项目里,每次都是只改配置数组和样式,定位公式一行没动过。