CSS数学函数这个话题,最早让我觉得“值得认真研究”,是因为一个很普通的需求:站点首页有个环形菜单,8个功能入口要等间距排在一个圆上。在那之前我的做法很经典——在 JS 里写Math.cos(2 * Math.PI * i / 8)算坐标,算完把left/top塞进元素的style属性,页面一缩放或者菜单数量一变,还得重新跑一遍。后来我把目标换成“尽量用纯 CSS 实现”,这才真正沉下心把 CSS 的三角函数和指数函数从头到尾翻了一遍。这篇文章就是当时实践下来的完整记录,覆盖函数语法、单位陷阱、真实案例和一些很容易踩的坑,适合不满足于只会calc(50% - 20px)的前端开发。
1. 为什么我要在 CSS 里“硬算”数学:从一次环形菜单说起
1.1 环形菜单的旧方案:JS 算完塞给 CSS
先说这个环形菜单。8 个按钮等距分布在半径 120px 的圆上,第 i 个按钮的角度是45deg * i,坐标公式并不复杂:
const el = document.querySelectorAll('.menu-item'); el.forEach((item, i) => { const angle = Math.PI / 180 * 45 * i; // 转成弧度 item.style.left = Math.cos(angle) * 120 + 'px'; item.style.top = Math.sin(angle) * 120 + 'px'; });这个方案在当时没什么问题,但我心里一直不舒服:坐标本身就是纯展示层的东西,为什么非要让 JS 参与不可?后来项目改版,菜单项从静态 8 个变成后台配置的 6~12 个不定值,动画还要在菜单展开时逐个有延迟地飞入。JS 版本越写越重,监听窗口resize又要重新计算——CSS 自定义属性这个时候已经普及,我突然意识到:如果 CSS 自己能算三角函数,环形排列根本不需要 JS。
1.2 CSS 数学函数的定位:它不是给你写业务逻辑的
不少人听到“CSS 有三角函数”第一反应是“那我以后在 CSS 里写游戏物理”。这种理解方向不对。CSS 数学函数的价值不在于替代 JS 做复杂算法,而在于把“和布局、视觉表现形式强相关”的计算下沉到样式层,配合 CSS 变量实现实时联动。
以环形菜单为例,纯 CSS 思路是:定义角度变量--angle,每个菜单项设置不同的值,然后直接用calc把圆的半径和角度相乘:
.menu-item { position: absolute; left: 50%; top: 50%; margin: -24px 0 0 -24px; transform: translate(calc(120px * cos(var(--angle))), calc(120px * sin(var(--angle)))); } .menu-item:nth-child(1) { --angle: 0deg; } .menu-item:nth-child(2) { --angle: 45deg; } .menu-item:nth-child(3) { --angle: 90deg; } /* ... */注意这里的顺序:cos对应水平方向transform: translateX,sin对应垂直方向translateY,因为 CSS 的坐标系是 x 轴向右、y 轴向下的,角度按顺时针方向计算。这个特性和数学课堂上习惯的“逆时针为正”正好相反,实际用的时候容易绕进去,我第一次就因为没注意把菜单排成了镜像的位置。
1.3 支持矩阵:2023 年之后才算真正可用
在立项之前先看兼容性。三角函数sin()、cos()、tan()、asin()、acos()、atan()、atan2()和指数函数pow()、sqrt()、hypot()、log()、exp()都属于 CSS Values and Units Level 4 的内容,浏览器支持情况如下(以我测试时的稳定版为准):
| 函数组 | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
| 三角函数 | 111+ | 108+ | 15.4+ |
| 指数与对数 | 111+ | 108+ | 15.4+ |
@property注册自定义属性 | 85+ | 128+ | 16.4+(部分) |
也就是说,2023 年 3 月 Chrome 111 发布之后,这套函数在主流桌面浏览器里才算是“能正经用”。@property的情况更复杂一些,后面第 5 章我会专门讲它和动画插值的关系。这个兼容时间点很重要,如果你要兼容 2022 年以前的浏览器,数学函数基本只能做渐进增强,不能做硬依赖。
2. 三角函数组:sin、cos、tan 以及它们的逆运算
2.1 参数单位是第一个坑:deg、rad 还是百分数
CSS 里三角函数接收的参数有三种写法:带角度单位的数值、无单位的数值、百分比。很多人第一次写sin()直接写sin(30),得到的结果不是 0.5,而是sin(30rad)的结果——这里无单位数字按弧度解析。30 弧度约等于 1718.87 度,它的正弦值大约是 -0.988,很容易让初学的人以为函数坏了。
我的建议是日常开发一律写deg或者turn。角度单位之间的关系很方便换算:
| 单位 | 含义 | 换算 |
|---|---|---|
deg | 度 | 1turn = 360deg |
grad | 梯度 | 1turn = 400grad |
rad | 弧度 | 1turn = 2π rad |
turn | 圈数 | 最直观的循环单位 |
百分数在三角函数里会先按360deg解析,也就是说100%相当于一整圈。这个设计对动画挺友好:进度从 0% 到 100%,正好对应sin走完一个完整波形。但实际上手时我还是推荐尽量用deg,可读性更好,排查问题也容易。
三角函数返回的都是无单位数值。比如sin(30deg)的值是 0.5,它不能直接当长度用,必须乘以一个带单位的量:calc(100px * sin(30deg))才是长度。反过来,如果你想用sin()的结果作为角度,比如rotate(...),必须给它乘一个角度单位,比如rotate(calc(sin(30deg) * 1turn)),直接写rotate(sin(30deg))浏览器会把你搞懵,因为rotate()期望的是角度,而sin()返回的是数字。
2.2 圆形排列:不写一行 JS 的多边形菜单
回到环形菜单,我最终给出的方案是配合nth-child用变量递增角度,这样菜单项数量变化时只改 CSS,不需要动 JS:
:root { --menu-count: 8; --menu-angle: calc(360deg / var(--menu-count)); } .menu-item { --i: 0; /* 在 nth-child 里覆盖 */ --angle: calc(var(--i) * var(--menu-angle)); left: 50%; top: 50%; position: absolute; transform: translate(-50%, -50%) translate(calc(120px * cos(var(--angle))), calc(120px * sin(var(--angle)))); } .menu-item:nth-child(1) { --i: 0; } .menu-item:nth-child(2) { --i: 1; } .menu-item:nth-child(3) { --i: 2; } /* ... */这套写法里我没有直接写死角度值,而是用--i和总数量算出每个项目对应的角度。想改成 12 个菜单,全局把--menu-count改成 12 就行,剩下全自动。为了让菜单旋转时的角度与坐标联动,还可以在同一个transform里再加一重rotate(var(--angle)),让每个图标都正对圆心——这在纯 CSS 里做旋转木马、轮播指示器的时候特别常用。
2.3 用 sin 驱动波浪和摆动:动画节奏不再只会 linear
三角函数在动画里的价值,本质上是把“线性进度”变成“波浪周期”。比如实现水波纹一样的上下浮动,过去要么用animation-timing-function: ease-in-out硬凑,要么让设计师导出动画曲线,其实用sin可以写出更精确的疏密节奏。配合自定义属性--t从 0 走到 1,水平位移可以写成:
@keyframes wave { from { --t: 0; } to { --t: 1; } } .wave { --t: 0; animation: wave 3s linear infinite alternate; transform: translateX(calc(100px * sin(calc(var(--t) * 1turn)))); }var(--t) * 1turn这段的含义是:当--t从 0 增长到 1,角度从 0turn 增长到 1turn,正好是一个完整正弦波周期。因为 CSS 的sin返回双极值(-1 到 1),位移就会先向右再向左,往复摆动。把1turn换成2turn,同一个动画周期内就能包含两个波浪,视觉频率直接翻倍,不需要改动画时长。
同理可以做钟摆效果。钟摆的物理特征是在平衡位置速度最快、两端速度为零,这一点sin天然贴合。把旋转角度写成:
.swing { transform-origin: top center; transform: rotate(calc(-15deg * cos(var(--t)))); }--t从 0 到 1 时,cos(var(--t))从 1 走到 -1,角度从 -15deg 平滑摆到 +15deg 再摆回来。动画时长和角度幅值都能轻松控制。做倒计时、加载动画、消息提醒的呼吸灯,用的都是这个套路。
2.4 反三角函数:从坐标反推角度
三角函数之外,反三角函数在实际里也很有用,尤其是atan2()。它接收两个参数:纵坐标y和横坐标x,返回从原点指向(x, y)的向量角度,范围是[-180deg, 180deg]。
最典型的场景是一个跟随鼠标方向的箭头或准星。鼠标位置相对于目标元素左上角的偏移量--dx、--dy由 JS 写入(这个只能 JS 来,因为鼠标坐标本身是运行时事件),CSS 侧用atan2算箭头旋转角:
.crosshair { transform: rotate(atan2(var(--dy), var(--dx))); }atan2相比atan的好处是它根据 x、y 的符号自动判断象限,不需要你手动修正角度。比如鼠标在元素左上方时,atan2会返回负角度,箭头自动指过去,不会出现 180 度的翻转错误。
asin()、acos()、atan()在 CSS 里的使用频率低不少,但在一些“给角度求比例”的场景也会用到。比如你已知一个圆弧上某点的 y 坐标占比,想反推它在圆上的角度位置,就可以asin()一把得到角度,再用角度驱动旋转或者进度环。这类函数返回的是带角度类型的值,可以直接传给rotate(),这和sin()返回无单位数字的习惯刚好相反,别记反了。
3. 指数与对数函数:幂、平方根与非线性世界
3.1 pow:非线性缩放和缓动的另一个思路
pow(A, B)接收两个数字参数,返回 A 的 B 次方。注意它和三角函数有个关键差异:参数和返回值都是无单位数字,所以使用频率最高的地方是配合其他单位做缩放。
举一个真实例子。商品卡片上的价格数字,我们希望它随卡片层级有非线性大小变化——上级列表里的数字略大,下级子项稍小。线性减字号太死板,用pow可以做出“开头变化快、后面变化慢”的递减曲线:
.fee-item { font-size: calc(18px * pow(var(--level), -0.2)); }当--level从 1 增加到 5,字号分别约是 18px、15.6px、14.4px、13.6px、13.1px。视觉上开头两级差距大,后面迅速趋平,这比单纯calc(18px - var(--level) * 2px)那种线性减法自然得多。类似的还有图标大小、间距、圆角的层级化设计。
pow在缓动函数里更常用。CSS 的缓动一般用cubic-bezier(),但如果你想实现标准的 easeOutCubic 公式1 - (1 - t)^3,用pow可以直接写进transform:
@property --t { syntax: '<number>'; initial-value: 0; inherits: false; } .card { --t: 0; transition: --t 0.8s linear; transform: translateX(calc(400px * (1 - pow(calc(1 - var(--t)), 3)))); } .card:hover { --t: 1; }这里关键点是transition: --t 0.8s linear配合@property让数字变量可以插值,然后transform里实时计算最终的位移。从视觉上看,卡片开始移动快、终点前减速,效果和ease-out一致,但好处是你可以任意魔改公式——比如用 5 次方实现更强的减速曲线,或者组合多个pow做弹性缓动,完全不受cubic-bezier()曲线形状的限制。
3.2 sqrt 和 hypot:两个求距离的朋友
sqrt(A)就是算术平方根,比较简单,要求 A 必须大于等于 0,负数会触发无效值,后面第 5 章会专门说 NaN 的问题。
hypot(A, B)更值得单独拎出来聊。它计算的是sqrt(A² + B²),也就是直角三角形的斜边长度,或者两个点之间的欧几里得距离。为什么专用函数比手写开方更推荐?两个原因:一是可读性好,hypot(var(--dx), var(--dy))一眼能看出你在算距离;二是在某些实现里它能避免中间平方结果溢出,这在数值范围比较大的场景下有实际意义。
圆形扩散的涟漪效果里,经常需要根据某个点离中心点的距离来决定半径。比如雷达扫描的中心在卡片左上角,目标点的横纵偏移是--dx、--dy,用hypot算出距离后,再配合calc把它映射成圆半径或者不透明度,整体公式比手写sqrt简洁非常非常多。
3.3 组合拳:exp 与 sin 联手的阻尼振荡
exp(A)返回自然常数 e 的 A 次方,log(A, B)返回以 B 为底(不写则默认自然对数)的对数。单独用exp常见于增长型动画,比如进度条开始慢、后面越来越快;但它在动效里真正的杀伤力是配合sin做阻尼振荡。
经典弹簧动效的位移公式是x = A * e^(-λt) * sin(ωt):指数部分负责衰减,让振幅越来越小;正弦部分负责往复振荡。用 CSS 写出来大概是:
.spring { --p: 0; transition: --p 1.2s cubic-bezier(0.2, 1.4, 0.4, 1); transform: translateX( calc( 120px * exp(calc(-3 * var(--p))) * sin(calc(var(--p) * 6.28318)) ) ); }这里exp(-3 * var(--p))提供衰减,sin(var(--p) * 6.28318)提供振荡,两个乘在一起就是你熟悉的“弹回来再停住”效果。配合 transition 的反拨曲线,能让按钮弹跳、列表滑入都带上一股“物理感”。log我用的少一些,但做一些音频可视化柱状图时,用它把大范围的数值压到可感知的小范围很顺手,相当于数学上的“对数压缩”。
4. 实战复现:涟漪光圈扩散与商品卡片数字动效
4.1 涟漪光圈扩散:cos 调出两头淡、中间亮
搜索热词里“css涟漪光圈扩散”很有代表性,我直接用数学函数实现过一遍。先看核心思路:涟漪的本质是一个从小变大、同时逐渐消散的圆环。消散过程不应该是线性的,而是“中间最清晰,两头逐渐隐没”,用cos来调度透明度正好。
准备两个伪元素构成双层光圈,配合@property注册一个进度变量--ripple,动画驱动它从 0 走到 1:
@property --ripple { syntax: '<number>'; initial-value: 0; inherits: false; } .ripple::before, .ripple::after { content: ""; position: absolute; inset: 0; border-radius: 50%; background: rgb(59 130 246 / 0.4); --ripple: 0; animation: rippleGrow 2.4s ease-out infinite; } .ripple::before { transform: scale(calc(0.2 + var(--ripple) * 1.8)); opacity: calc(0.75 * cos(calc(var(--ripple) * 180deg))); } .ripple::after { animation-delay: 1.2s; transform: scale(calc(0.2 + var(--ripple) * 1.8)); opacity: calc(0.75 * cos(calc(var(--ripple) * 180deg))); } @keyframes rippleGrow { 0% { --ripple: 0; } 100% { --ripple: 1; } }cos(var(--ripple) * 180deg)的作用是:进度从 0 到 1 时,角度从 0deg 走到 180deg,余弦值从 1 平滑地经过 0 再到 -1,我们取它作为不透明度系数,就实现了“开始和结束都是透明的,中间某个时刻最清晰”的呼吸感。这和用线性插值做出来的涟漪完全不同——线性插值通常会让光圈显得死板,到结尾才突然消失,而cos版本每一步都顺滑过渡。
我测试时发现一个细节:如果只用opacity: calc(1 - var(--ripple)),光圈一开始最清晰,随着扩散越来越淡,视觉效果偏“甩出去”,但少了涟漪特有的“中心重新蓄力”的感觉。加上cos之后,两个伪元素交错延迟,后一圈会在前一圈将尽未尽时从中央重新亮起来,涟漪的层次感就出来了。
4.2 商品卡片价格跳动:回弹放大用 exp 来做
价格数字在“加入购物车”的瞬间跳动一下,是很常见的反馈动效。线性放大太小气,纯 CSS 动画写三四个关键帧又太啰嗦,用exp配合sin一次搞定:
gauge-price { --pop: 0; transition: --pop 0.6s ease-out; transform: scale(calc(1 + exp(calc(-4 * var(--pop))) * sin(calc(var(--pop) * 12.566)) * 0.35)); } gauge-price.added { --pop: 1; }公式拆开看:--pop从 0 到 1,sin(var(--pop) * 12.566)在一个周期内完成两次完整振荡,exp(-4 * var(--pop))让振荡迅速衰减到趋近 0。最终效果是数字先猛地放大到约 1.35 倍,然后带着轻微回弹稳定到原本大小。12.566 这个常数其实是4π,它决定了回弹次数:每多2π就多一个完整振荡,调起来很直观。
4.3 渐进增强:@supports 兜住老浏览器
数学函数虽好,但如果在不支持的老浏览器里直接把公式写进transform,整个元素的位移会失效,布局可能直接崩掉。我的习惯是用@supports做两套样式:
.menu-item { left: 50%; top: 50%; position: absolute; transform: translate(-50%, -50%); /* 降级方案:初始放在圆的正上方 */ } @supports (left: calc(1px * cos(0deg))) { .menu-item { transform: translate(-50%, -50%) translate(calc(120px * cos(var(--angle))), calc(120px * sin(var(--angle)))); } }@supports的检测方式很讲究:写一个能验证解析规则的属性,比如left: calc(1px * cos(0deg))。左边属性值无关紧要,关键是让浏览器尝试解析cos()是否可用。这样支持的就走高级样式,不支持的保留静态降级方案,功能不完整但页面不会错乱。
5. 实操中我踩过的坑:NaN、插值失败与本地调试
5.1 自定义属性默认插值不了:必须靠 @property
这是我最想提醒别人的一个坑。CSS 自定义属性在默认情况下是“字符串型”的,你写--t: 1它就是一个字符串"1",浏览器并不知道这是个数字。于是你写:
.element { --t: 0; transition: --t 1s linear; } .element:hover { --t: 1; }期望看到--t从 0 平滑过渡到 1,实际上它会瞬间从 0 跳到 1,过渡完全不生效。因为默认情况下自定义属性不参与插值。
解决办法就是本章代码里反复出现的@property注册,显式告诉浏览器这个变量是<number>类型:
@property --t { syntax: '<number>'; initial-value: 0; inherits: false; }这之后transition: --t 1s linear才能真正按毫秒插值。注册syntax: '<angle>'、'<length>'也同理。可以理解成:给浏览器一张“这个变量的说明书”,它才知道怎么在动画里计算中间值。建议所有把 CSS 变量用于数学函数和动画的项目,都尽早用@property把变量类型定死,否则后面调试会非常痛苦。
5.2 NaN 的三种典型来源:负数开方、单位混用、参数类型错位
数学函数返回无效值NaN的情况,调试时控制台不会给你报错,只会让样式静默失效,所以要把常见来源背下来。
第一种是sqrt()传入负数。CSS 里没有复数概念,sqrt(-1)直接无效,整个计算属性就废了。代码里如果一个距离值可能为负,记得先abs()包一层。
第二种是pow()的底数为负数且指数不是整数。数学上(-2)^0.5无实数解,CSS 同样按无效处理。我在写缓动公式时踩过一次:pow(calc(var(--t) - 1), 2.5),--t起始时是 0,底数变成 -1,指数又是小数,整条transform直接不渲染。排查半天才发现是底数符号问题,最后改成pow(abs(calc(var(--t) - 1)), 2.5)才好。
第三种是三角函数参数放错单位类别。sin()期望数字或角度,你传一个带长度单位的值进去,比如sin(100px),解析会失败。这类错误在代码里最好闻:一眼看上去单位是“合理的”,但函数根本不接受这个单位。写之前先确认函数的输入输出单位,这点比函数本身更值得花时间记。
5.3 本地双击 HTML 打不开样式表:file 协议的限制
搜索热词里有一条报错很典型:Access to CSS stylesheet at 'file:///C:/Users/...' blocked。这种问题我在本地项目里也遇过,原因不是你的 CSS 写错了,而是浏览器出于安全策略限制,对file://协议下跨目录引用的样式表做了拦截。尤其是你在桌面上新建一个 HTML,又引用了桌面另一个文件夹里的 CSS,经常就会触发。
解法很简单:本地开发一律起一个静态服务,不要直接双击 HTML。用 VS Code 的 Live Server 插件,或者命令行:
cd 项目目录 python -m http.server 8080然后浏览器访问http://localhost:8080。这样既绕开了file://的限制,也让后续调试网络请求、观察加载时序更接近真实环境。
5.4 性能提醒:数学函数不是不能用,但别把几十个元素全塞满
CSS 数学函数最终由浏览器在布局和绘制阶段实时计算,和预处理器算好的静态值相比,确实会多消耗一些 CPU。对单个、少量元素来说开销可以忽略,但如果一个页面里循环渲染了 50 个菜单项,每个项都有好几层calc加三角函数,我实测在低端安卓机上会出现肉眼可见的掉帧。
我的实践经验是两个约束:一是把能提前算的常量提前算好,比如圆周排列时某些固定角度可以直接写死,不必每个都走一遍复杂公式;二是动画中高频变化的属性尽量只锁定在transform和opacity上,不要让三角函数参与width、height、margin这类会引起重排的属性,否则每一帧都要重新布局,开销会成倍放大。
最后再分享个小技巧:调试这些数学函数时,别急着开 DevTools 一点点看,直接在浏览器控制台执行CSS.supports('width', 'calc(1px * sin(1deg))'),一秒确认当前浏览器支不支持这套特性。支持矩阵年年变,代码里多写一层@supports兜底,比任何“未来可期”的承诺都靠谱。