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 个变成后台配置的 612 个不定值动画还要在菜单展开时逐个有延迟地飞入。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: translateXsin对应垂直方向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 / EdgeFirefoxSafari三角函数11110815.4指数与对数11110815.4property注册自定义属性8512816.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 360deggrad梯度1turn 400gradrad弧度1turn 2π radturn圈数最直观的循环单位百分数在三角函数里会先按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可以直接写进transformproperty --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 走到 1property --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 到 1sin(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兜底比任何“未来可期”的承诺都靠谱。
