纯CSS画冒热气杯子:几何拆解、热气动画与性能优化 纯 CSS 画一只冒热气的杯子这题目看着像个新手练手的小玩意但真动手写过的人都知道它坑不少形状用几层伪元素拼、热气用什么做才不像白烟囱、动画跑起来手机发烫……我第一次做这个效果时热气是拿五个 div 各挂一个blur滤镜去做透明度动画在电脑上看着还行扔到一台中端手机上直接掉帧滚动页面时热气一顿一顿的。后来把动画属性和滤镜彻底拆开才把帧率拉回来。这篇就把这只杯子从几何拆分到热气飘散再到性能收尾的完整过程摊开讲重点不是给你一段能跑的 CSS3 代码而是讲清楚每一步为什么这么选——毕竟复制粘贴的人多改得动的人少。适合已经会写基础选择器和keyframes、想做出有点质感的纯 CSS 插画动画的同学也适合被纯 CSS 做不了复杂效果这句话劝退过的人重新校准一下判断。1. 为什么一只纯 CSS 的杯子值得写一遍1.1 纯 CSS 插画的适用边界先把预期摆正。纯 CSS 绘图不是什么都能干它有明确的能力边界几何形状、渐变、圆角、阴影、变换、滤镜这些它做得很溜复杂的有机曲线、手绘笔触、位图纹理它就吃力了。一只杯子的结构恰好落在它的舒适区里——圆柱体椭圆加矩形、圆环把手、梯形杯身收口全是能用border-radius和clip-path精确描述的规则几何。而热气这个东西是半透明的、边缘虚化的、形态不断变化的用纯 CSS 做就属于在边界上试探。我判断一个效果该不该用纯 CSS 做看三条形状能不能被参数化描述、动画是不是只涉及有限的变换、资源体积是否真的重要。杯子的形状完全可以参数化热气虽然形态模糊但模糊这件事本身就能交给filter: blur()处理正好绕开了精确建模。至于第三点一个纯 CSS 的 loading 插画不需要额外请求图片首屏不会因为一张 PNG 拖慢这在做骨架屏或者空状态占位时是实打实的优势。反过来说如果你要做的是品牌插画、角色立绘、有渐变网格的复杂海报那老老实实用 SVG 或者位图别跟自己较劲。CSS 绘图的性价比在小、轻、可参数化、可动画这四个字上超出这个范围就是自找麻烦。1.2 热气动画的三条技术路径与选择理由热气这块我实际试过三条路各有各的脾气先摆个表省得后面绕圈子。实现路径核心手段优点代价纯 CSS 伪元素 模糊多个椭圆元素叠加filter: blur()keyframes控制位移与透明度零依赖、体积几乎为零、参数全在 CSS 里改颜色改节奏都方便形态重复感明显模糊半径调大后性能吃紧SVG 滤镜噪声feTurbulencefeDisplacementMap做真实扰动形态自然边缘有颗粒感和真实扰动部分浏览器的滤镜性能比 CSS 模糊更差调试成本高静态序列图 / 动图预渲染若干帧靠steps()或定时切换形态最可控美术资源说了算体积大改一个色调要重出一套图缩放会糊我最后选的是第一条。原因很朴素这个效果的核心诉求是氛围,不是写实。真实水蒸气是湍流用任何手段都很难省钱地模拟出来而人的眼睛对有东西在往上飘、边缘是虚的、越飘越淡这个模式非常宽容只要节奏对了五条糊糊的椭圆就足够骗过感知。再加上整个 demo 是拿来做空状态占位的体积和可配置性比写实度重要得多。有个中间路线值得提一句主体用纯 CSS只把热气那一层换成一张很小的 SVG 加 CSS 动画。这样热气形态可以做得更有细节动画仍然由 CSS 驱动。我在另一个项目里就是这么折中的效果不错但本文还是按纯 CSS 走到底把这个方案的极限摸清楚。2. 杯子的几何拆解先用盒模型把形状算清楚2.1 杯身的梯形clip-path 与 border 技巧的取舍杯身是个上宽下窄的圆角梯形这是整只杯子里唯一有点不规矩的形状。我试过两种画法差别挺大。第一种是老派的 border 梯形把border-bottom设成实色、border-left和border-right设成透明靠边框的斜接效应斜切出梯形。这招兼容性好到离谱连很老的浏览器都认。缺点是致命的——border是硬边扣不出圆角杯底那两个漂亮的圆弧根本没戏而且高度一改角度跟着乱变参数之间互相耦合。第二种是clip-path: polygon()直接给出四个顶点坐标。杯底想要圆角就再叠一层border-radius不行——clip-path会把圆角直接切掉。所以我的实际做法是外层容器负责border-radius做成下半部分的大圆弧内层用clip-path做斜切两者叠加斜边和圆角就都有了。写法大概这样.cup-body { position: relative; width: 140px; height: 170px; border-radius: 12px 12px 60px 60px / 12px 12px 46px 46px; background: linear-gradient(100deg, #f7f2ea 0%, #ffffff 38%, #e9dfd1 100%); overflow: hidden; } .cup-body::before { content: ; position: absolute; inset: 0; background: inherit; clip-path: polygon(6% 0, 94% 0, 82% 100%, 18% 100%); }这里有个容易翻车的细节border-radius的斜杠语法水平半径 / 垂直半径。杯底的圆角在垂直方向要压扁一点否则视觉上像个气球。我一开始用border-radius: 12px 12px 60px 60px做出来杯底圆得像个碗完全没有杯子该有的稳重感。改成椭圆半径之后立刻正常了。经验是只要形状是被压扁的圆就别用等半径斜杠语法一定要上。2.2 杯口、液面与把手椭圆和圆环的拼装杯口是整只杯子的神态所在——它决定了你是从斜上方看这只杯子还是从正前方平视。做法很简单一个宽高比大概 3:1 的椭圆border-radius: 50%背景给个径向渐变让边缘亮、中间稍暗就有那么点弧面的意思了。液面咖啡、茶、水都行是一个比杯口小一圈的椭圆压在杯口下方几个像素的位置用radial-gradient做深浅。这里有个小技巧液面别做成纯色用两段径向渐变靠近嘴的一侧给一点高光能立刻做出液体的反射感。加个box-shadow: inset做杯壁的接触阴影边缘就不会飘在杯子上。把手是个圆环最省事的写法是用border.cup-handle { position: absolute; right: -34px; top: 34px; width: 58px; height: 62px; border: 9px solid #f2ebdf; border-left-color: transparent; border-radius: 50%; transform: rotate(6deg); }border-left-color: transparent让圆环断在杯身那一侧看起来就像把手插进了杯壁里。但要注意这个把手必须压在杯身的下面不然断口会被杯身挡住一半露出一个诡异的缺口。我早期版本就是把把手放在杯身之上结果断口位置卡在杯壁内侧像是被啃掉一块。正确做法是把手先画、杯身后画或者干脆把手z-index设得比杯身低一层。另外rotate(6deg)这点小角度很关键完全水平对称的把手会显得像贴纸稍微歪一点点就有手工感的松弛。2.3 层级堆叠顺序谁该压在谁上面纯 CSS 绘图的顺序感本质上是z-index和 DOM 顺序的管理。这只杯子从后到前大概分六层我用一张表定死避免后面加特效时互相打架。层元素说明1桌面背景 / 光晕最底层暖色径向渐变2杯子倒影镜像 渐隐压在杯子下面3把手必须在杯身之前4杯身主体5液面 / 杯口内壁压在杯身内6杯口前缘 热气热气夹在杯口前后缘之间才有从杯里冒出来的空间感最后一条是最容易被忽略的。热气如果整个压在杯口上方看起来像是贴在杯口外面的一层雾把杯口的前半圆弧单独抽出来盖在热气之上热气就有了从杯口内侧升起、被杯沿挡住下半截的层次。这个操作我管它叫前缘遮挡纯 CSS 里用一个半椭圆伪元素就能实现几乎不花什么成本但对真实感的提升是数量级的。3. 热气的形态建模不靠图片怎么做出雾感3.1 单个热气的形状径向渐变 椭圆 模糊一条热气的本体我最后定下来的是细长椭圆 径向渐变 模糊三件套。原因是这样的纯色椭圆加模糊之后边缘会均匀虚化中间却还是实心一块看着像棉花糖而径向渐变可以从中心向外自然衰减配合模糊边缘过渡会柔和得多。.steam span { position: absolute; bottom: 100%; left: 50%; width: 16px; height: 74px; margin-left: -8px; border-radius: 50%; background: radial-gradient( ellipse at 50% 82%, rgba(255, 255, 255, 0.85) 0%, rgba(255, 255, 255, 0.45) 42%, rgba(255, 255, 255, 0) 76% ); filter: blur(5px); will-change: transform, opacity; }径向渐变的圆心我放在了50% 82%也就是偏下的位置——因为热气是从杯口长出来的根部最浓、顶部最淡圆心偏下正好符合这个浓度分布。如果圆心放在正中间热气会显得上下一样重像一根悬空的柱子。blur(5px)这个数值是调出来的。3px 以下能看出椭圆的边界没有雾感8px 以上热气会糊成一团白斑失去条状的方向感。在不同尺寸的容器里这个值要跟着缩放所以我后来把它抽成了 CSS 变量。3.2 三条还是五条数量、错峰与不重复感纯 CSS 做重复图案最大的敌人是规律感。人是模式识别动物三秒钟就能看出你每条热气长得一模一样、间隔分毫不差那股子假味立刻上来。解决办法有两个方向数量错落、节奏错峰。数量上我最后定的是四条中间两条偏粗两侧两条偏细偏淡。五条以上性能开始有压力每条都是一次模糊合成三条又太空热气聚不成团。四条是一个挺舒服的平衡点小尺寸下也不拥挤。错峰靠animation-delay。这里有个反直觉的点延迟不要用等差数列。0s / 0.8s / 1.6s / 2.4s这种等间隔动起来以后你会发现热气是一波一波齐步走视觉上依然是规律的。我改成0s / -1.3s / -2.7s / -0.6s用负延迟让动画直接从中间开始播放四条热气的相位彻底打散看起来就像各自独立生长。.steam span:nth-child(1) { left: 34%; width: 12px; animation-delay: -0.6s; animation-duration: 3.8s; } .steam span:nth-child(2) { left: 46%; width: 17px; animation-delay: -2.7s; animation-duration: 4.4s; } .steam span:nth-child(3) { left: 58%; width: 15px; animation-delay: -1.3s; animation-duration: 4.1s; } .steam span:nth-child(4) { left: 68%; width: 10px; animation-delay: 0s; animation-duration: 3.4s; }每条热气的animation-duration也各不相同这一步比延迟更关键——同一个周期下哪怕相位打散了长时间看还是能感觉出它们其实同步。周期不同循环点就不会撞在一起重复感被压得很低。这招在做飘雪、落叶、光点漂浮时通用属于那种看一眼就懂、但很多人懒得更细调的地方。4. 让它真的飘起来关键帧节奏的调试4.1 外层管位移、内层管摆动把两种运动拆开这一节是我认为整个效果里最值得讲的部分。最初我把热气所有的运动都塞进一个keyframes上飘translateY、左右摇translateX、放大scale、消失opacity一条曲线全包。结果动起来非常机械——上升和摆动是严格同步的热气往上走多少、就往左偏多少画出的是完美的斜线一点烟的随机感都没有。正确做法是把两种运动拆到两层元素上外层元素只负责垂直上升内层元素只负责左右摆动。因为两层各有自己的动画周期和缓动曲线叠加出来的轨迹就是两条不同频率曲线的合成天然不重复。.steam-rise { animation: rise 4.4s linear infinite; } .steam-rise span { animation: sway 2.6s ease-in-out infinite alternate, fade 4.4s linear infinite; } keyframes rise { 0% { transform: translateY(0) scale(0.9); } 100% { transform: translateY(-118px) scale(1.25); } } keyframes sway { 0% { transform: translateX(-7px) rotate(-4deg); } 100% { transform: translateX(9px) rotate(5deg); } } keyframes fade { 0% { opacity: 0; } 12% { opacity: 0.8; } 62% { opacity: 0.5; } 100% { opacity: 0; } }注意sway用了alternate周期 2.6 秒和rise的 4.4 秒不成整数倍关系。这种互质感的周期配比能撑很久不露馅。如果把它俩设成 2s 和 4s你就会看到热气每四秒重复一次同一个歪扭的姿态非常出戏。4.2 关键帧时间点的分配与缓动函数rise我用的是linear不是ease。这点可能有点反常识——物理世界里热气上升应该是先快后慢受浮力加速、再受阻力减速怎么反而线性原因是热量散失导致的透明度衰减、以及放大效果已经在视觉上给了减速感如果位移再用ease-out两个减速叠在一起热气会显得上升无力飘两下就定住。真正需要调缓动的是fade。我用linear让透明度均匀衰减但在 12% 处插了一个opacity: 0.8的峰值——热气刚从杯口出来的时候不该是透明的得有个冒出来的过程。0% 到 12% 这段淡入很短就是模拟那股刚溢出杯口的水汽。关键帧节点位移透明度目的0%00藏在杯口内不可见12%约 -14px0.8刚溢出杯沿最浓40%约 -47px0.62上升主体开始散70%约 -82px0.28明显变淡100%-118px0完全消散无缝接回 0%最后一条值得强调opacity在 100% 回到 0和 0% 的 0 是同一个值所以循环时不会有跳变。所有无限循环动画的第一帧和最后一帧只要属性值不同就一定会在衔接处闪一下。我早期版本 100% 是opacity: 0.05结果每条热气循环时都有一次几乎看不见、但确实存在的闪烁在低亮度屏幕上特别刺眼。4.3 用 CSS 变量统一控制节奏与尺寸做到这一步我意识到这套东西如果只写死一组像素值复用价值几乎为零——换个容器尺寸就得重算一遍。所以后面全部改成了变量驱动.cup-scene { --cup-w: 140px; --cup-h: 170px; --steam-rise: 118px; --steam-dur: 4.4s; --steam-blur: 5px; --steam-color: 255, 255, 255; } .steam span { filter: blur(var(--steam-blur)); background: radial-gradient( ellipse at 50% 82%, rgba(var(--steam-color), 0.85) 0%, rgba(var(--steam-color), 0.45) 42%, rgba(var(--steam-color), 0) 76% ); animation-duration: var(--steam-dur); } keyframes rise { to { transform: translateY(calc(var(--steam-rise) * -1)) scale(1.25); } }有个细节keyframes里的calc()用变量是没问题的但animation-duration不能用calc()做乘法——只接受时间单位字面量或者变量。所以周期我是直接给变量的要做快慢调节就换一个变量值而不是乘系数。这一点我在一个带速度滑块的交互 demo 里踩过写了calc(var(--steam-dur) * 0.5)浏览器直接忽略动画退化成默认时长。改成变量之后深色背景的版本只需要覆盖--steam-color热气的颜色立刻从白雾变成微微发灰的烟。冷饮版本把--steam-rise调小、--steam-blur加大就变成了冷气下沉的观感当然下沉要把位移方向反过来这个后面说。5. 质感收尾高光、倒影与桌面暖光5.1 高光条与边缘暗部纯平面的形状永远像贴纸加一层高光就能站起来。杯身的高光我用一条窄窄的竖向渐变.cup-body::after { content: ; position: absolute; left: 16%; top: 8%; width: 14px; height: 74%; border-radius: 50%; background: linear-gradient( to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.9) 26%, rgba(255, 255, 255, 0.25) 72%, rgba(255, 255, 255, 0) 100% ); filter: blur(2px); }高光条为什么要两端渐隐因为高光在圆柱面上的分布是中间强、上下两端衰减如果做成一个上下等宽的白条会像贴了一道口香糖。同理高光的位置不能居中得偏左——默认光源在左上杯子的左侧亮、右侧暗。我一开始把高光放中间做出来像一个会发光的罐子非常诡异。边缘暗部则是给杯身加box-shadow: inset右侧和底部各给一点暗色颜色别用纯黑用杯子固有色的深色版本比如米白杯身配rgba(150, 130, 105, 0.35)。用纯黑压会显脏这是所有画写实一点的东西时通用的坑。5.2 桌面倒影mask-image 的渐隐技巧倒影是那种加了就贵十倍的细节实现起来却只要几行.cup-reflection { position: absolute; bottom: 0; left: 50%; transform: translateX(-50%) scaleY(-1); opacity: 0.22; filter: blur(3px); -webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0) 8%, #000 76%); mask-image: linear-gradient(to top, rgba(0,0,0,0) 8%, #000 76%); pointer-events: none; }scaleY(-1)做镜像mask-image做从下往上的渐隐——注意方向镜像之后坐标轴也翻了所以渐变方向要跟着反我第一次写的时候渐变方向弄反倒影变成了上实下虚像悬浮的鬼影。还有个性能提醒倒影如果是把整个杯子 DOM 复制一份,元素数量翻倍热气动画也会跟着复制一份。我做法是倒影层只做静态的形状杯身 液面不放热气——真实的倒影里水汽本来也几乎看不见删掉反而更干净还省了一半的合成开销。5.3 暖光氛围与混合模式最后一步是加氛围。杯子下面垫一个大的径向渐变光斑颜色取暖橙或者暖黄的低透明度mix-blend-mode: screen让它在深色背景上发光而不是盖住.cup-glow { position: absolute; left: 50%; bottom: -18%; width: 240%; height: 90%; transform: translateX(-50%); background: radial-gradient( ellipse at center, rgba(255, 176, 92, 0.42) 0%, rgba(255, 176, 92, 0.12) 38%, rgba(255, 176, 92, 0) 72% ); filter: blur(8px); mix-blend-mode: screen; pointer-events: none; }mix-blend-mode有个坑父元素一旦设置了isolation: isolate或者触发了新的层叠上下文比如transform、filter、opacity 1混合模式的作用范围就会被限制在父容器内。我在一个页面里把这个杯子塞进了一个带transform的卡片容器光晕直接不发光了排查了半天才发现在容器上。要么把光晕跟杯子放同一层级要么把mix-blend-mode那层移到容器外面。另外一个替代方案是干脆不用混合模式直接用radial-gradient的高透明度叠加效果差一点但完全可控、不吃混色计算。放在列表里大量重复渲染时比如一个页面里有十几只杯子当占位图我倾向于这个保守方案。6. 性能、降级与真机踩坑记录6.1 blur 是性能黑洞动画属性的选择回到开头说的那个掉帧问题。当时的写法是keyframes badFade { 0%, 100% { opacity: 0; filter: blur(2px); } 50% { opacity: 0.7; filter: blur(9px); } }问题出在filter参与了动画。模糊半径每帧变化浏览器就没法把这一层预先合成好每一帧都要重新做一次模糊计算。四条热气同时跑等于每秒重算 240 次模糊中端手机直接跪。修法很简单模糊值固定只动transform和opacity。这两者可以走合成层GPU 处理起来几乎不花代价。模糊的变大效果改用scale去模拟——元素放大时固定的模糊半径在视觉上也会跟着变模糊因为像素被拉伸了。这个替代方案在视觉上几乎无差性能却是天壤之别。/* 推荐模糊静态动画只碰 transform / opacity */ .steam span { filter: blur(var(--steam-blur)); will-change: transform, opacity; animation: fade var(--steam-dur) linear infinite; }will-change也别滥用只在真正会被动画修改的属性上声明。曾经我图省事给整个场景容器加了个will-change: transform结果容器被提升成一个巨大的合成层内存占用飙升滚动的时候整个页面都在重新光栅化。声明will-change的范围要跟动画元素一样小而精确这是我从那次事故里记住的一条。6.2 兼容降级与 prefers-reduced-motionclip-path的兼容性现在没什么问题了但如果你要覆盖更老的浏览器可以留一条降级线用supports (clip-path: polygon(0 0))包住斜切部分的样式不支持的时候退回圆角矩形——形状是从梯形杯变成直筒杯但至少不崩。另一个必须加的是动效偏好适配。有些用户开了系统的减弱动态效果这时候整屏飘动的热气就是纯粹的干扰。一行代码的事media (prefers-reduced-motion: reduce) { .steam span, .steam-rise { animation: none; } .steam span { opacity: 0.35; } }这里别只是animation-play-state: paused那样热气会僵在半空中比动起来更别扭。直接关掉动画让热气保持一个低透明度的静态形态视觉上还是一个完整的画面只是不动了。移动端还有一个隐形成本blur在移动 GPU 上的实际开销远高于桌面。我的做法是用媒体查询在小屏上把热气减到三条、模糊半径从 5px 降到 3.5px。视觉损失很小但在低端机上帧率差别肉眼可见。这个取舍没什么技术含量纯粹是经验——所有依赖滤镜的效果都该给移动端留一条降级线。6.3 排查表动画不动的几种常见原因最后把我在这个效果上踩过的动画不生效问题整理成表遇到相似症状可以先对号入座。现象大概率原因处理方式热气完全不出现元素定位在杯口之外被容器overflow: hidden裁掉给热气层单独开一个不裁剪的父容器或加overflow: visible热气出现但不飘keyframes里的transform与元素本身的transform冲突后者覆盖了动画拆成内外两层外层挂动画内层保留静态定位变换循环衔接处闪一下首尾帧的属性值不一致把 100% 的属性值改成与 0% 完全相同动画时快时慢不自然多条热气用了等差延迟且周期相同打乱延迟让每条animation-duration取不同值手机上掉帧filter或box-shadow参与了关键帧动画把这类属性移出keyframes只动transform/opacity热气颜色在深色背景上发灰渐变用了纯白实色而非径向衰减改径向渐变中心浓边缘透明其中第二条坑了我最久。原因是我一开始图省事直接给.steam span本身写了left: 50%; transform: translateX(-50%)做水平居中然后又在这个元素上挂带transform的动画。结果动画每帧都在覆盖那个居中用的位移热气直接从杯子的左边缘冒出来——因为translateX(-50%)被吃掉了。排查这种问题最快的办法是在动画暂停状态下animation-play-state: paused看元素的实际位置一眼就能看出静态变换有没有丢。多说一句关于杯口遮挡这个细节的调法如果发现热气看起来像从杯子外面飘过来的检查一下热气层的z-index是不是比杯口前缘还高。正确的层级是液面 热气 杯口前缘。这个顺序看起来挑剔但它是这只杯子从一张图变成一个场景的分水岭——热气被杯沿挡住的那一小截就是空间深度本身。我在实际项目里给这只杯子做过四套配色变体热咖啡、热茶、冷饮、空杯占位同一套结构只换了 CSS 变量。最省事的复用方式是把它抽成一个自定义元素或者一组>