纯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 滤镜噪声 | feTurbulence+feDisplacementMap做真实扰动 | 形态自然,边缘有颗粒感和真实扰动 | 部分浏览器的滤镜性能比 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% | 0 | 0 | 藏在杯口内,不可见 |
| 12% | 约 -14px | 0.8 | 刚溢出杯沿,最浓 |
| 40% | 约 -47px | 0.62 | 上升主体,开始散 |
| 70% | 约 -82px | 0.28 | 明显变淡 |
| 100% | -118px | 0 | 完全消散,无缝接回 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-motion
clip-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 变量。最省事的复用方式是把它抽成一个自定义元素或者一组>