
CSS实现元素圆形排列听起来像是一个必须交给JS的活儿但实际上几行transform的组合就能排得非常规整。前阵子我接了一个周年庆活动页需要在主视觉周围摆一圈用户头像数量还随时可能变12个、16个、20个都有可能。当时我的第一反应是做一版JS坐标计算——循环生成div给每项算left和top后来发现完全没必要。纯CSS配合变量内部怎么循环、圆心怎么定位、头像怎么转正样式层几行就能写完。这篇文章我会从原理讲到完整案例涵盖平面圆环、3D立体环、椭圆、螺旋这些常见变体最后把最容易踩的坑也一并列出来。适合被“排圆”需求困扰的前端也适合想彻底搞懂transform组合的同学。1. 圆形排列的第一步先理解 transform 组合的“方向魔法”1.1 为什么 rotate translate 能排成圆很多人第一次写圆形排列时会困惑rotate和translate都是很基础的功能为什么组合起来就能让元素均匀落到圆周上关键在于CSS的transform操作作用在“局部坐标系”上而不是固定的屏幕坐标系。单独执行rotate(45deg)元素只是在原地转了45度位置没有任何变化。单独执行translateY(-120px)元素会沿屏幕的Y轴方向向上移动120px。两者一组合情况就变了——旋转会先改变元素局部坐标轴的朝向之后的位移是“沿着旋转后的新方向”进行的。这就好比开车rotate决定了车头朝向translate是踩油门前进。先打方向盘再踩油门车就会沿着新朝向开到以原点为圆心、半径为油门距离的圆周上如果先踩油门再打方向车只会停留在正前方那一条直线上。用这个思路就不难理解如果给8个元素分别做rotate(0deg)、rotate(45deg)、rotate(90deg)这样的旋转再让它们统一translateY(-140px)每个元素就会沿着不同的径向方向移动相同距离最终正好落在同一个圆上。这里还有一个关键的执行顺序问题。CSS transform列表的写法是“左侧的函数后作用右侧的函数先作用”。所以rotate(A) translateY(-R)的实际执行顺序是先translateY(-R)再rotate(A)不是。准确说右侧的translateY(-R)先作用于元素坐标系然后元素的坐标系方向已经被rotate改变了此时平移的参照系已经不一样。为了避免绕晕我习惯直接记结论在transform链里写在右边的函数先执行越靠左越后执行。这个顺序如果反了效果会非常离谱。比如写成translateY(-120px) rotate(45deg)所有元素会先同时移动到圆心中间上方同一个位置再各自原地转45度最终全部叠在一起。我第一次写的时候就踩过这个坑花了好一会儿才反应过来是顺序问题。1.2 一段能直接落地的排布代码理解了原理先给一段最简可用的HTML和CSS。假设有8个元素要排成圆形div classcircle div classcircle__item1/div div classcircle__item2/div div classcircle__item3/div div classcircle__item4/div div classcircle__item5/div div classcircle__item6/div div classcircle__item7/div div classcircle__item8/div /div.circle { --n: 8; /* 元素总数 */ --r: 140px; /* 圆半径 */ position: relative; width: 360px; height: 360px; } .circle__item { --i: 0; /* 每个元素覆盖这个值表示序号 */ --angle: calc(var(--i) * 360deg / var(--n)); position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; margin-left: -28px; /* 让元素中心对准圆心 */ margin-top: -28px; display: flex; align-items: center; justify-content: center; background: #f0f4ff; border-radius: 50%; transform: rotate(var(--angle)) translateY(calc(var(--r) * -1)) rotate(calc(var(--angle) * -1)); }以及对应的序号变量映射.circle__item:nth-child(1) { --i: 0; } .circle__item:nth-child(2) { --i: 1; } .circle__item:nth-child(3) { --i: 2; } .circle__item:nth-child(4) { --i: 3; } .circle__item:nth-child(5) { --i: 4; } .circle__item:nth-child(6) { --i: 5; } .circle__item:nth-child(7) { --i: 6; } .circle__item:nth-child(8) { --i: 7; }注意这里我没有用translate(-50%, -50%)来居中元素而是用负margin。原因很简单如果translate(-50%, -50%)出现在transform链里它自身也是一次位移变换会跟后面的rotate和translateY共同参与矩阵乘法破坏“先旋转再径向外移”的纯逻辑。用负margin把元素中心对准圆心transform链就能保持最清晰的旋转位移结构排查问题也方便得多。1.3 元素自身朝向的校正上面的代码里有一个细节很多人第一次看会忽略为什么最后一个rotate要把刚才的角度反向再转一次因为如果不做这一步元素虽然能排成圆但每个内容都是歪的。rotate(45deg) translateY(-140px)做完之后数字“1”会跟着旋转45度整体朝外侧躺rotate(90deg)的元素数字会横过来rotate(180deg)的会倒过来。一个圆环上的图标如果全是歪的视觉上就会非常乱。解决办法就是translateY之后再执行一次反向旋转rotate(calc(var(--angle) * -1))。这一步把被带歪的元素坐标系转回世界坐标系相当于“位移完成后把元素正回来”。整个链路是先反向预转、再沿预转方向外移、最后正向转回。最终效果是元素位置在圆环上内容保持正面不变。如果元素是圆形图标或者本身没有方向性最后这个反向旋转可以省掉。但只要是带文字、带箭头、带方向感的卡片这一下绝不能漏。后面实战案例里菜单上的文字标签能正常横排靠的就是这一步。2. 动态数量怎么办CSS 变量 calc() 自动算角度2.1 固定写死 nth-child 的痛如果你和我一样常用vue的v-for或者react的map循环渲染一组div你会发现给每个循环出来的div手动标序号是件很烦的事。元素固定8个、12个时写8行nth-child还能接受一旦可复用到其他页面数量变成16个、20个就要复制一大堆选择器维护起来非常痛苦。更麻烦的是如果运营后台返回的用户头像数量是动态的你不可能在CSS里预先写好50条nth-child规则。这时候就需要把“数量”和“序号”从CSS里抽出来通过JS动态设置CSS变量const wrap document.querySelector(.circle); const items [...wrap.children]; wrap.style.setProperty(--n, items.length); items.forEach((el, idx) { el.style.setProperty(--i, idx); });三行JS搞定。CSS部分的计算逻辑完全不用动页面数据从12个变成18个时nth-child不需要再写新规则。这是纯CSS方案里比较舒服的折中——样式逻辑仍在CSSJS只负责把动态数据注入变量。2.2 CSS 变量和 calc 的分工整个动态方案能否跑起来核心在于CSS变量和calc的配合是否严谨。有几个经验值得记一下--n和--i的取值必须是纯数字不能带引号。写成--i: 2会被当成字符串calc直接不计算。--i从0开始编号而不是从1开始。因为角度公式是序号 × 360deg ÷ 总数从0开始才能让第一个元素落在0度位置。如果从1开始第一个元素就会偏了一个步长整圈元素角度整体偏移看起来像“最后缺了一个”。分母--n不要带单位calc(360deg / 8)合法但写成calc(360deg / 8px)就是完全错误的语法。calc里180deg、360deg这些角度单位可以混算CSS是支持的。不需要手动换算弧度。实际使用中还有一个非常香的组合SCSS的for循环可以批量生成nth-child规则。如果项目用了SCSS写这几行就能覆盖任何固定数量场景for $i from 0 through 7 { .circle__item:nth-child(#{$i 1}) { --i: #{$i}; } }这样编译出来的CSS里变量映射由构建工具自动生成比手写8条规则省心得多。不过要注意的是SCSS只是编译期的写法页面真正跑起来后变量值已经固定在CSS文件里了数量变化时依然要靠JS动态注入。2.3 三种“算位置”的方案横向对比圆形排列的实现方式不止一种。我从实际项目角度把常见的三种方案放一起对比过方案核心思路适用场景兼容性双rotate旋转方案rotate(A) translateY(-R) rotate(-A)通用圆形排布、最推荐较全面数学坐标方案left/top cos()/sin()计算椭圆、需要直接定位的场景依赖现代浏览器JS计算方案遍历列表逐项赋坐标或变量数量高度动态或需要动画需要JS参与双rotate方案最大的优势是对浏览器版本要求低而且不需要元素有固定尺寸也能得到漂亮的圆心对齐效果。数学坐标方案在椭圆场景下非常舒服因为椭圆直接给a、b两个半径就行但cos/sin这些三角函数只在新版浏览器里支持项目如果还要兼容老旧环境就得加垫片。JS计算方案最灵活但样式逻辑散落在JS里维护性略差。所以我的默认选择是数量固定或能接受少量JS注入时优先双rotate CSS变量的组合遇到椭圆等特殊几何需求再用数学坐标方案补充。3. 实战案例做一个 8 节点圆形快捷菜单3.1 HTML 结构和样式变量理论讲完直接上一个完整案例。这次做的是一个8节点的圆形快捷菜单每个节点是圆形按钮内部有图标和文字hover时放大点击时出现一圈涟漪光晕。这个案例几乎覆盖了圆形排列在实际页面里遇到的所有典型问题。HTML结构尽量语义化用ul li承载ul classmenu li classmenu__itemspan首页/span/li li classmenu__itemspan分类/span/li li classmenu__itemspan活动/span/li li classmenu__itemspan订单/span/li li classmenu__itemspan购物车/span/li li classmenu__itemspan收藏/span/li li classmenu__itemspan足迹/span/li li classmenu__itemspan更多/span/li /ul为了保证圆心正确菜单容器需要明确宽高。这里我直接给了420px的正方形同时也把几个关键尺寸抽成CSS变量方便以后整体缩放.menu { --n: 8; --r: 170px; --size: 64px; position: relative; width: 420px; height: 420px; margin: 0; padding: 0; list-style: none; }容器如果不设置高度绝对定位的top: 50%就没有参照系元素会四散乱跑。这是一个非常容易忽略的底层坑后面会专门展开说。3.2 旋转排布与朝向校正菜单项的样式在原理代码基础上升级了一下加了宽高、圆角、背景色和过渡动画.menu__item { --angle: calc(var(--i) * 360deg / var(--n)); position: absolute; left: 50%; top: 50%; width: var(--size); height: var(--size); margin: calc(var(--size) / -2); /* 负margin让中心对准圆心 */ border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #ffffff; box-shadow: 0 4px 12px rgba(31, 46, 80, 0.08); transform: rotate(var(--angle)) translateY(calc(var(--r) * -1)) rotate(calc(var(--angle) * -1)); transition: transform 0.25s ease, box-shadow 0.25s ease; } .menu__item:nth-child(1) { --i: 0; } .menu__item:nth-child(2) { --i: 1; } .menu__item:nth-child(3) { --i: 2; } .menu__item:nth-child(4) { --i: 3; } .menu__item:nth-child(5) { --i: 4; } .menu__item:nth-child(6) { --i: 5; } .menu__item:nth-child(7) { --i: 6; } .menu__item:nth-child(8) { --i: 7; }实现到这里菜单已经能均匀排成圆环。文字的横排效果由最后那个反向rotate保证。如果不加最后一段反向旋转八个“首页、分类、活动”这些文字就会像表盘刻度一样全部朝外歪着阅读性极差。这个细节做圆形菜单时一定要记牢。3.3 hover、点击涟漪和字体渐变的交互细节交互部分有两个最容易踩的坑。第一个是hover放大。因为.menu__item本身已经有长长的transform链hover时如果直接写成transform: scale(1.15)会覆盖原有transform导致元素瞬间跳回圆心。正确写法是把原链完整保留scale加在最后.menu__item:hover { transform: rotate(var(--angle)) translateY(calc(var(--r) * -1)) rotate(calc(var(--angle) * -1)) scale(1.15); box-shadow: 0 12px 24px rgba(31, 46, 80, 0.16); }第二个是点击涟漪。涟漪本质是一个从小变大、透明度降为0的扩散动画但涟漪层不能直接放在.menu__item本身上因为item的transform已经被占据。所以我把涟漪放到伪元素上用独立动画控制.menu__item::after { content: ; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.7); opacity: 0; pointer-events: none; } .menu__item.is-ring::after { animation: ring 0.7s ease-out forwards; } keyframes ring { 0% { transform: scale(0.5); opacity: 0.7; } 100% { transform: scale(2); opacity: 0; } }这里用box-shadow画圆环而不是border是有讲究的。border在scale的过程中会被拉伸变粗看起来像一圈粗橡皮筋box-shadow会跟随元素缩放但粗细保持不变更接近水波扩散的自然感。JS部分只需要负责给点击的节点加类名并触发reflow以便重复播放动画const menu document.querySelector(.menu); menu.addEventListener(click, (e) { const item e.target.closest(.menu__item); if (!item) return; item.classList.remove(is-ring); void item.offsetWidth; // 强制reflow让动画能连续播放 item.classList.add(is-ring); });如果菜单标题想做渐变文字可以直接加在span上.menu__item span { font-size: 13px; font-weight: 600; background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }渐变文字配合环形菜单整体视觉会比纯色高级一些。这个手法本身很简单就是把背景渐变裁到文字形状里注意-webkit-text-fill-color: transparent要同时写上。4. 真正常踩的坑圆心偏移、朝向错乱和“幽灵间距”4.1 默认 padding 与容器尺寸圆形排列看起来只是一个transform公式的事但真正放到页面里各种默认样式会不知不觉把圆心带偏。最典型的罪魁祸首是ul自带的那一坨padding。浏览器默认会给ul添加左padding一般约40px。如果你的容器直接用了ul并且没有重置padding绝对定位的left: 50%虽然大部分情况还是基于容器宽度计算但在某些盒模型组合下多出来的padding会参与容器尺寸计算导致圆心偏移好几个像素。尤其当你后续还要加涟漪、波纹、半透明遮罩这类覆盖全容器的伪元素时这点偏差就会被放大成肉眼可见的错位。有个经验可以分享凡是用ul/ol做圆形排列第一件事就是重置三大件——margin: 0、padding: 0、list-style: none。这三大件不reset干净后面排查圆心偏了几像素的问题会很痛苦。容器本身也需要一个确定尺寸。如果容器高度没有显式指定top: 50%没有参照高度元素可能全部跑出视口之外。要么给容器写死宽高要么用aspect-ratio: 1让正方形跟随宽度.menu { width: min(80vw, 420px); aspect-ratio: 1; }这种方式在响应式页面上表现很好半径用小屏时也比较爽。4.2 默认外边距与 box-sizing第二个容易出问题的是子元素的默认间距。很多人习惯给按钮、卡片设置统一的margin来拉开间距但在圆形排列里margin会叠加到transform位移之外破坏等距分布。圆形布局中每个元素的位置完全由半径和角度决定不要额外设置margin-bottom、margin-left这类边距。如果需要视觉间距优先扩大半径--r或者让元素本身大一点、透明留白多一点。box-sizing同样值得检查。如果给.menu__item设置了边框并且容器没有设置box-sizing: border-box再加border后会撑大元素半径实际位移会偏。稳妥做法是全局加一句*, *::before, *::after { box-sizing: border-box; }这能避免很多“宽度明明设了64px加了个边框怎么变成66px”的迷惑情况。另外还有“幽灵空白”问题。如果子元素是inline-blockHTML换行符会产生几像素的空隙加上默认line-height的影响位置会莫名错位。圆形排列里的item最好用block、flex或absolute避免inline-block带来的基线影响。4.3 最后几个元素要不要特殊处理这个点很有意思。很多人从做列表布局的习惯里带过来一个惯性排最后一个元素时总想处理一下“最后”的样式比如去掉右边距、去掉分隔线、用:last-child加个特殊样式。在圆形排列里这种思维往往是多余的。圆环是闭合的360度均分之后最后一个元素和第一个元素之间的夹角恰好就是均分步长本身。它们天然首尾相接不需要人为给最后一个元素留空间。如果你发现“最后一个元素的位置不对”多数情况下不是最后一个的问题而是变量编号出了问题——最典型的是--i从1开始而不是从0开始导致所有元素整体偏了一个步长看起来像第一和最后之间缺了一块。如果你在排网格布局处理“一行两个最后两个元素不加伪类”这类问题确实要用nth-child精细控制但排圆形布局时请先把这套思路放下。圆上的每个节点都是平等关系公式对所有元素一视同仁不需要针对最后一项做任何特殊修正。遇到圆环看起来不对先检查--n是否等于实际数量再检查--i是否从0开始比折腾伪类高效得多。5. 从平面到立体rotateY translateZ 的3D环形排列5.1 rotateY(60deg) translateZ(300px) 到底长什么样平面圆环做熟之后3D版本的环形排列是另一个常见需求。网上经常有人在问transform: rotateY(60deg) translateZ(300px)这个写法出来到底是什么样子我用自己的语言描述一下。单个元素执行这个变换效果是元素先绕自身竖直中轴旋转60度然后沿着旋转后所朝的方向向外推出300px。从正面看这个元素不再正对着你而是明显偏向屏幕一侧并且由于透视关系会有近大远小的收缩感。如果你把10个元素分别用rotateY(0deg)、rotateY(36deg)、rotateY(72deg)这样递增的角度组合起来每个元素都会落到以Y轴为中心、半径300px的圆环上空间上形成一个立体环绕阵列。这就是经常说的3D旋转木马效果。注意这个和平面版本的核心区别平面版用rotatetranslateY元素在屏幕二维平面里移动3D版用rotateYtranslateZ元素走出屏幕深度方向。5.2 用 preserve-3d 和视距参数搭立体环搭一个最简3D环形排列需要三层视距容器、3D空间容器、环上的卡片。div classstage div classcarousel div classcard1/div div classcard2/div div classcard3/div div classcard4/div div classcard5/div /div /div.stage { perspective: 1200px; width: 600px; height: 400px; display: flex; align-items: center; justify-content: center; } .carousel { --n: 5; --r: 260px; position: relative; width: 200px; height: 200px; transform-style: preserve-3d; } .card { --i: 0; --angle: calc(var(--i) * 360deg / var(--n)); position: absolute; left: 50%; top: 50%; width: 120px; height: 160px; margin: -60px 0 0 -60px; background: linear-gradient(135deg, #a18cd1, #fbc2eb); border-radius: 12px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 24px; transform: rotateY(var(--angle)) translateZ(var(--r)) rotateY(calc(var(--angle) * -1)); }几个关键点perspective必须放在父容器上不是放在卡片上。它决定整个3D空间的视距数值越小透视变形越强1200px左右相对自然。transform-style: preserve-3d要加在.carousel上否则子卡片的3D变换会被压平。卡片内部仍然用负margin把中心对准定位点和平面版同理。最后那个rotateY(calc(var(--angle) * -1))同样是转回正面的作用。去掉它卡片会随环绕方向旋转后侧卡片出现镜像形成更有包裹感的视觉效果保留的话卡片始终正面面对观察者类似图片墙环绕。两种都没有绝对的对错按设计需求选。如果想让整个环自动旋转直接给.carousel加一圈旋转动画.carousel { animation: spin 12s linear infinite; } keyframes spin { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }动画加在外层不影响每张卡片自己的朝向校正逻辑。5.3 3D旋转正负判断的核心规则3D旋转里的正负方向是最容易搞混的地方。CSS的坐标系里x轴朝右、y轴朝下、z轴朝向屏幕外旋转正方向符合右手定则。但y轴朝下这一点会让大脑的直觉判断完全失效——你以为的正方向预览出来可能是反的。我的建议是不要死背方向而是用一套试错流程快速确认先给元素写一个rotateY(90deg)。打开浏览器预览观察它偏向了哪边。如果发现方向和期望相反把90改成-90即可。这个流程十几秒就能完成比查文档、翻记忆都快。在实际项目中我几乎每次都会先这样验证一次再批量应用到整个环形。因为正负号一旦错了整圈元素会朝反方向排列看着就像镜像翻转排查成本不低。还有一个容易踩的坑.stage如果设置了overflow: hidden3D环在转动时超出容器范围的部分会被裁剪看起来像是卡片凭空消失。需要完整展示立体环时overflow要放开或者把容器做得足够大。6. 不止正圆椭圆、螺旋、文字绕排的扩展玩法6.1 椭圆排列用 cos/sin 而不是去压扁 transform有时候需求不是正圆环而是椭圆环比如控制台里的一圈指标卡片水平方向半径大、垂直方向半径小。很多人第一反应是用scaleY(0.6)把正圆压扁但这样做会把元素本身也跟着压扁——圆形头像变成鸡蛋头文字变矮比例完全失真。正确做法是用椭圆参数方程直接定位水平位置取决于a * cos(angle)垂直位置取决于b * sin(angle)。现代CSS已经支持三角函数可以这么写.ellipse { --a: 220px; /* 水平半径 */ --b: 120px; /* 垂直半径 */ --n: 12; } .ellipse__item { --angle: calc(var(--i) * 360deg / var(--n)); position: absolute; left: calc(50% var(--a) * cos(var(--angle))); top: calc(50% - var(--b) * sin(var(--angle))); transform: translate(-50%, -50%); }这个写法里角度为0时元素在最右侧角度90度时元素在正上方。因为屏幕坐标系的y轴朝下所以垂直方向用减号50% - b * sin(angle)让sin为正时往上走否则元素会跑到下方。如果希望起点在12点方向把角度整体减90度即可。6.2 半径递增的螺旋排列螺旋排列和圆环唯一的区别是半径不再恒定而是随着序号递增。直接给--r套一个calc.spiral__item { --angle: calc(var(--i) * 40deg); --r: calc(80px var(--i) * 12px); transform: rotate(var(--angle)) translateY(calc(var(--r) * -1)) rotate(calc(var(--angle) * -1)); }角度每项固定递增40度半径每项增加12px元素就会由内向外绕成一条螺旋。这种形式特别适合做时间线、数据标签云、实验性视觉展示。我做过一个“按权重分布关键词”的页面权重高的词半径小、离中心近权重低的词半径大、离中心远再利用螺旋排布效果比传统列表有意思很多。配合hover放大和字体渐变能做出很出彩的视觉。6.3 装饰交互里的涟漪扩散和数字滚动前面实战案例里已经写了点击涟漪这里再补充一个和圆形排列很配的装饰效果数字加载动画。很多圆形菜单、环形数据卡片上会放一个统计数字比如“当前在线人数”“累计访问量”数字从0滚动到目标值的过程可以完全用CSS做property --num { syntax: integer; initial-value: 0; inherits: false; } keyframes count { from { --num: 0; } to { --num: 2024; } } .counter { counter-set: num var(--num); animation: count 2s ease-out forwards; } .counter::before { content: counter(num); }这个方案依赖property属于比较新的CSS特性老浏览器里数字不会滚动但会直接显示最终值因为forwards保留的是初始状态实际需要降级处理。我的建议是这种装饰动画可以做渐进增强主流程不依赖它有动画是惊喜没有也不影响功能。如果你确实需要全兼容就老老实实用JS来做数字滚动。不管用哪种方式我有个调试习惯一直保留先把所有item的background-color或outline调成高对比度确认圆环、圆心、朝向都对之后再换回正式样式。圆形排列一旦出错肉眼很难一眼看出是角度算错、圆心偏了还是元素被压扁了带颜色调试能帮你把变量隔离出来省下大量排查时间。另外如果项目要兼容特别老的浏览器优先用双rotate方案cos/sin、property这些新特性留着做渐进增强就好。