做 UI 和前端这几年,我发现自己跟“呼吸灯”这三个字纠缠的次数特别多。小到按钮上的加载圆点,大到智能硬件面板里的状态灯,很多产品都想用“呼吸”来表达“我活着、我还在工作、我收到了消息”。但真正动手做的时候,大部分人的第一版实现都是简单闪烁:透明度从 0 到 1,再瞬间变回 0。结果做出来怎么看怎么像故障灯,一点“呼吸感”都没有。
所谓“智能呼吸灯(透明度/缩放动画)”,说白了就是用透明度加缩放这两个属性,模拟人呼吸时的柔和节律。这个项目思路一旦吃透,你能用到的地方非常多:网页端消息提醒、耳机/音箱的灯效模拟、智能家居面板的状态反馈、大屏可视化的数据点亮起,几乎全是同一套逻辑。这篇文章我不会只丢一个 Demo,而是把效果拆解、参数选择、CSS 实现、JavaScript 动态控制、性能优化和常见坑位都讲一遍。新手可以直接照着抄,老手也能在旁边捡到几个调参和避坑的思路。
1. 效果拆解:为什么透明度和缩放必须一起用
1.1 呼吸感从哪来:光不是灯,光是一团“气”
先想一个问题:一个圆点从暗变亮,再变暗,这是不是呼吸灯?视觉上是,感觉上不是。真正的呼吸灯,亮度变化是持续的、有坡度的,而且发光范围也会有轻微膨胀。你盯着一盏呼吸灯看几秒钟,会感觉它在“起伏”,而不是在“开关”。
这里的关键就是透明度和缩放要同时参与。透明度负责亮暗强弱,缩放负责体量大小。亮度上来的时候,光的范围跟着大一点点;亮度下去的时候,光的范围也缩回来。两个变量叠加,光就从“一个颜色固定的元素”变成了“一团有体积感的光气”。
这个逻辑放哪都成立。网页里的呼吸点是这样,硬件的 LED 灯效也是这样。硬件是靠 PWM 调亮度、靠扩散片和半透明罩子做虚化,网页里没有物理光,就只能靠 opacity 和 scale 去骗眼睛。骗得好了,用户的感知就是“柔和、舒服”;骗得不好,就是“这按钮是不是坏了”。
我在做智能设备模拟面板的时候,最早只改 opacity,圆点确实在呼吸,但总觉得少了点东西。后来把 scale 加上,呼吸瞬间就活了。透明度负责“呼吸”,缩放负责“体积”,两者缺一不可。
1.2 控制曲线:别用线性,用正弦
呼吸感的核心不是“会变”,而是“怎么变”。很多人写动画喜欢用 linear,线性变化。线性问题在于速度是匀速的,亮起来和暗下去的节奏一样,机械感很强。真实呼吸不是匀速的:吸气的时候先慢后快再慢,呼气也是弧线。用数学语言说,这就是一个正弦曲线。
前端做动画时,最简单的做法是给 animation 加 easing。比如animation: breathe 2.4s ease-in-out infinite。ease-in-out 已经比 linear 好很多,起始和结束都比较平缓,中间加速,正好符合呼吸的柔和感。
如果你想更精确,可以直接用cubic-bezier(0.45, 0, 0.55, 1)。这个曲线起点比 ease-in-out 更自然。我的个人经验是:用 ease-in-out 已经能满足 80% 的场景,真觉得不对,再微调 cubic-bezier,不要一上来就背参数。
还有一个容易踩坑的细节:透明度不要从 0 到 1。完全透明到完全不透明,中间会有明显的“翻折感”,尤其在深色背景上,0 到 1 的变化会让圆点像在“闪烁报警”。呼吸灯建议把透明度限制在0.25 ~ 1之间。也就是说,最暗的时候它也不是完全消失,而是保持一点存在感;最亮的时候也不一定是纯不透明白,而是留一点通透。
1.3 缩放到底要多大
缩放幅度比很多人想象的小。写代码时一激动就写scale(0.5) -> scale(1.5),那已经不是呼吸灯,是弹力球。呼吸灯的缩放范围一般控制在0.96 ~ 1.06左右。元素小、视觉要求高的时候,可以放到1.08 ~ 1.12;元素大,比如大屏上一个几十像素的数据点,缩放幅度反而要更小,否则会有跳动感。
还有一点容易忽略:transform-origin。圆形容器里,缩放的默认基点是元素中心,但如果你的结构不是正方形,或者内部有文字、边框,默认基点可能不是你想要的。做圆点呼吸灯时,请显式写上:
transform-origin: center center;这样可以保证缩放是以中心为基准,不会从左上角“歪斜”。我见过不少项目,圆点呼吸到一半突然往右下角跑,就是没设 transform-origin。
2. 纯 CSS 实现:一个最小可用的呼吸灯
2.1 结构怎么搭
先给一个最小结构。这里的关键点在于:不要把文字和圆点放在同一个可动画元素里,否则文字会跟着一起透明度变化,用户会看不清。你把状态文字放在外面一层,圆点单独做呼吸动画。
<div class="lamp-wrap"> <div class="lamp-core"></div> <span class="lamp-text">待命中</span> </div>外层容器负责布局,内层.lamp-core是一个圆点,专门拿来跑透明度/缩放动画。这样做的好处是:即使透明度降到 0.25,文字仍然是完整清晰的不透明状态,不会跟着一起“隐身”。
2.2 关键帧写法
.lamp-wrap { display: inline-flex; align-items: center; gap: 8px; } .lamp-core { position: relative; width: 24px; height: 24px; border-radius: 50%; background: rgba(56, 189, 248, 0.9); animation: breathe 2.4s ease-in-out infinite; transform-origin: center center; will-change: opacity, transform; } @keyframes breathe { 0% { opacity: 0.3; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } 100% { opacity: 0.3; transform: scale(0.96); } }50%对应呼吸的“吸气末”,此时透明度最高、体积最大。0%和100%对应“呼气末”,透明度最低、体积最小。来回往复,就形成了呼吸循环。
动画时长2.4s是我自己比较常用的值。成人一分钟呼吸大约 12 到 20 次,单次周期 3 到 5 秒,但 UI 上如果按这个真实频率做,用户会觉得偏慢。前端呼吸灯一般1.8s ~ 2.8s之间比较自然。太快像紧张情绪,太慢像卡死。
2.3 光晕和阴影怎么加才不卡
很多新手会直接在圆点上写一层大大的box-shadow,比如:
box-shadow: 0 0 40px rgba(56, 189, 248, 0.8);box-shadow 在不做动画的时候没问题,但如果你想让光晕也跟随呼吸一起变化,常见的做法是同时给 box-shadow 也加动画。这个做法在低端设备上会很吃力。box-shadow 变化属于绘制阶段的重计算,每帧都改,帧率会掉。
更稳妥的方式是给圆点加一个伪元素作为光晕层,让光晕层用背景渐变代替阴影,然后用transform和opacity驱动:
.lamp-core::before { content: ""; position: absolute; left: 50%; top: 50%; width: 100%; height: 100%; border-radius: 50%; background: radial-gradient( circle, rgba(56, 189, 248, 0.5) 0%, rgba(56, 189, 248, 0) 70% ); transform: translate(-50%, -50%) scale(1.8); animation: breatheGlow 2.4s ease-in-out infinite; z-index: -1; } @keyframes breatheGlow { 0% { opacity: 0.2; transform: translate(-50%, -50%) scale(1.4); } 50% { opacity: 0.8; transform: translate(-50%, -50%) scale(1.9); } 100% { opacity: 0.2; transform: translate(-50%, -50%) scale(1.4); } }注意伪元素里的transform同时包含translate(-50%, -50%)和scale(...)。如果漏掉 translate,光晕会从左上角开始缩放,中心点全乱了。这是一个我见过了不下五次的踩坑点。
如果你想要柔化效果,可以用filter: blur(...),但注意不要在动画里动态改 blur 的数值,那会有性能问题。建议静态 blur 值加动态 opacity/transform,视觉上也能有很好的柔光效果。
2.4 让它更完整:减少动画与主题适配
呼吸灯在“待机”状态可以一直动,但如果用户开启了系统“减少动态效果”,我们应该尊重这个设置。CSS 里有现成的媒体查询:
@media (prefers-reduced-motion: reduce) { .lamp-core, .lamp-core::before { animation: none; } }这样做了之后,呼吸灯会变成一盏静态指示灯。在无障碍场景下,呼吸动画本身不是问题,问题是不能只靠动画表达关键信息。比如“连接成功”这种状态,如果旁边没有文字说明,只靠灯光呼吸一次,用户可能根本注意不到。所以“智能呼吸灯”的智能,不只是动画智能,还包括对场景、对用户偏好的智能。
3. 用 JavaScript 让呼吸灯“聪明”起来
3.1 什么时候用 CSS 不够
纯 CSS 跑固定循环很方便,但产品里往往有多个状态:待机要慢一点,收到消息要明显一点,危险状态要急促一点。用 CSS 实现多套动画也能做,只要写几个不同的 class 切换就行,但如果你想根据设备实时数据、服务器推送、用户操作来动态调整呼吸频率和深度,CSS 动画就会变得很笨重。
这时候可以换成 JavaScript 驱动。核心不是用 setInterval 改透明度,那会非常卡。正确做法是requestAnimationFrame,配合正弦函数算出一帧的透明度和缩放值。
3.2 基于 requestAnimationFrame 的核心算法
先看核心代码:
const core = document.querySelector('.lamp-core'); const state = { duration: 2400, minAlpha: 0.25, maxAlpha: 1, minScale: 0.96, maxScale: 1.06, running: true, startTime: performance.now(), }; function update(time) { if (!state.running) return; const progress = ((time - state.startTime) % state.duration) / state.duration; const phase = progress * Math.PI * 2; const breathe = (Math.sin(phase - Math.PI / 2) + 1) / 2; const alpha = state.minAlpha + (state.maxAlpha - state.minAlpha) * breathe; const scale = state.minScale + (state.maxScale - state.minScale) * breathe; core.style.opacity = alpha.toFixed(3); core.style.transform = `scale(${scale.toFixed(4)})`; requestAnimationFrame(update); } requestAnimationFrame(update);这段代码里有一个小细节:Math.sin(phase - Math.PI / 2) + 1) / 2。如果不减Math.PI / 2,动画刚开始时sin(0) = 0,亮度会从中间位置开始,看上去像把呼吸灯从半空中“截断”了。减了Math.PI / 2之后,sin(-PI/2) = -1,加 1 再除以 2 等于 0,也就是从“最暗”开始,符合我们对呼吸灯从呼气末启动的预期。
requestAnimationFrame会在浏览器下次渲染前执行回调,能跟屏幕刷新率保持一致。这里只改opacity和transform,浏览器会走合成器,不会触发重新布局。如果在这个函数里读offsetWidth、getBoundingClientRect,就会强制同步布局,动画容易掉帧。这是一个很重要的性能习惯。
3.3 状态切换与节奏控制
把参数抽到 state 对象里的好处就是,随时能切换模式:
function setBreathMode(mode) { if (mode === 'idle') { state.duration = 2400; state.minAlpha = 0.25; state.maxAlpha = 1; state.minScale = 0.96; state.maxScale = 1.06; } else if (mode === 'notice') { state.duration = 1200; state.minAlpha = 0.3; state.maxAlpha = 1; state.minScale = 0.94; state.maxScale = 1.1; } else if (mode === 'alert') { state.duration = 600; state.minAlpha = 0.5; state.maxAlpha = 1; state.minScale = 0.9; state.maxScale = 1.15; } }急促不只是变快,还要让最低亮度抬高。这样视觉上会形成一种“紧张”的压迫感。这个思路放在实际项目里很常见:同样一个圆点,待机时是冷蓝色慢呼吸,收到提醒时变成亮橙色快呼吸,危险时变成红色高频呼吸。用户不用读文字,扫一眼灯光状态就能判断系统发生了什么。
完整结合 HTML/CSS/JS 的演示,其实就是把上面几个代码块拼在一起。我不建议在页面上同时挂多个requestAnimationFrame,多个动画都用同一个update循环管理,性能更好,逻辑也更清晰。
4. 影响范围与工程化经验
4.1 哪些场景适合透明度加缩放动画
呼吸灯动画的适用范围我筛了一下,至少包括下面几类:
| 场景 | 推荐周期 | 透明度范围 | 缩放范围 | 注意事项 |
|---|---|---|---|---|
| 页面按钮提示 | 1.6s | 0.4 ~ 1 | 1 ~ 1.04 | 别遮挡文字,弱化光晕 |
| 消息角标 | 1.2s | 0.3 ~ 1 | 1 ~ 1.08 | 要足够明显,可配合位置移动 |
| 大屏数据点 | 2.4s | 0.25 ~ 1 | 1 ~ 1.1 | 注意性能和批量节点优化 |
| 智能硬件灯效模拟 | 2.8s | 0.2 ~ 1 | 1 ~ 1.03 | 需要映射到真实灯光亮度 |
同样一套透明度/缩放动画理念,在这些场景里的参数差别很大。页面按钮的呼吸灯如果也做到 1.1 倍缩放,旁边文字会被挤得“跳起来”,观感很差。大屏上如果有几百个数据点,每个点都挂独立 animation 和 box-shadow,会卡到怀疑人生。参数必须因地制宜。
4.2 回答一个搜索热词:cst 怎么改变模型透明度
这几天很多人在搜“cst怎么改变模型透明度”。先做个区分:如果你搜的是“CSS 怎么改变模型透明度”,只是把 CSS 打成了 cst,那么在网页里,最直接的透明度控制有两种。一个是元素整体的透明度,用opacity: 0.5,它会连带着让所有子元素一起变透明。另一个是背景色或文字颜色的透明度,用rgba(0, 0, 0, 0.5)这种方式,只改变当前颜色通道,不会影响其他子元素的可见度。呼吸灯动画里既有 opacity 控制,也有 rgba 控制。核心层用 opacity,光晕层用 rgba,两者配合,视觉层次才丰富。
如果你真的用的是 CST Studio Suite 这类建模仿真软件,那“模型透明度”是显示样式设置,一般在视图窗口选中模型对象,然后在对象属性或显示设置里调整透明度。它只影响你看模型的视觉效果,不影响仿真计算参数。网页动画里的透明度是纯视觉表达,仿真软件里的透明度是操作辅助,本质是两个概念。
4.3 主题适配和性能检查
呼吸灯在深色背景下和浅色背景下,参数可能需要分开设计。深色背景可以适当把最小透明度调低一点,因为深色不容易让光显得脏;浅色背景则要小心,透明度太低了会看不见。建议把颜色和透明度都抽取成 CSS 变量:
.lamp-core { --lamp-alpha-min: 0.25; --lamp-alpha-max: 1; --lamp-color: rgba(56, 189, 248, 0.9); }换主题的时候只改 CSS 变量,动画代码不用动。Chrome DevTools 里可以打开 Rendering 面板,开启 Paint Flashing,观察动画过程中有没有大面积绿色闪烁。大面积闪烁说明触发了重绘,需要检查是不是改错了属性。再配合 Performance 面板录制一段动画,看帧时间是否稳定在 16ms 左右。
5. 常见问题与排查技巧实录
5.1 动画不出现或只闪一次
我遇到最多的原因是animation-iteration-count没写,或者写了但被覆盖了。animation: breathe 2.4s ease-in-out infinite这个简写里最后那个infinite很容易被后面的同名样式覆盖。排查时先在 DevTools 里看 Computed Style,如果animation-name为空,说明类名没生效或者被覆盖。
还有一种情况是元素初始状态为display: none,动画不会在元素显示的时候自动重新开始。如果有“隐藏后重新显示”的场景,可以考虑给容器加animation的重启逻辑,或者在 JS 里手动移除并重新添加 class。
5.2 透明度叠加导致背景变暗
如果你把opacity设置在父容器上,子元素会整体一起透明。文字、边框、背景都会变成半透明,看上去像是整块背景变暗了。解决办法是把发光点和文字拆成兄弟节点。发光点自己玩透明度和缩放,文字永远保持独立、清晰。还有一种情况是颜色本身用了半透明,叠加多层之后会变深,比如rgba(0, 0, 0, 0.5)叠两次,视觉上会接近 0.75 的黑色,不是 0.5。呼吸灯倾向于用亮色光晕,这个问题不算大,但如果你做暗色阴影效果,就要注意叠加问题。
5.3 缩放时文字或图标模糊
圆形呼吸灯里如果放了小图标,缩放倍数超过 1.1 之后,图标边缘会发虚。尤其在小尺寸元素上特别明显。解决办法有两个:要么缩小缩放幅度,要么让圆点本身尺寸大一点,比如把 24px 做成 32px,边缘抗锯齿会好很多。还可以给元素加transform: translateZ(0),把元素提到独立的合成层,让浏览器用 GPU 加速,能在一定程度上改善模糊感。但这招不要滥用,每个元素都开合成层,内存占用会上升。
5.4 几个问题速查表
| 现象 | 原因 | 解法 |
|---|---|---|
| 动画只跑一次 | animation-iteration-count 缺失 | 简写里加 infinite |
| 圆点从左上角缩放 | transform-origin 没设 | 设 center center |
| 光晕中心点偏移 | transform 里漏了 translate | 保留 translate(-50%, -50%) |
| 文字跟着半透明 | opacity 设在父容器 | 拆分成独立的兄弟节点 |
| 动画掉帧 | 每帧改 box-shadow 或触发 layout | 改用 opacity/transform 驱动 |
| 缩放后图标模糊 | 缩放倍数过大、元素太小 | 减小幅度或增大初始尺寸 |
做呼吸灯这件事,参数上的克制比炫技重要。我见过很多效果图做得很好看,真的落地到产品里,用户第一反应是“这个按钮是不是在报警”。透明度不要拉满到 0,缩放不要超过 1.1,周期不要太短。把这些底限记住,你做的呼吸灯至少是自然、舒服的。
我自己的习惯是:先把透明度调到 0.3 到 1,缩放调到 0.96 到 1.06,周期定在 2.4 秒,然后坐下来盯着它看十秒钟。十秒之内觉得“还有口气活着”,参数不用改;觉得“是不是卡了”,说明太慢;觉得“好焦虑”,说明太快。呼吸灯的效果判断没有标准答案,最终还是要靠眼睛和感受来收尾。