最近给一个体验治理 Agent 的聊天欢迎页补氛围时,我没有再加一张背景大图,而是把「云雾」做成了一个独立的 CSS layer。它在页面里缓慢游移,既让空状态不显得空,又不会干扰输入框、对话气泡和任务状态这些真正需要被看见的内容。

这件事看起来是视觉微调,实际很像前端工程里的控制问题:动画有了不代表体验变好了。云雾的范围、透明度、节奏和层级,只要有一个失控,就会立刻变成一层抢注意力的滤镜。

一句话总结

把背景云雾拆成一个不参与布局的绝对定位 layer,再用两组不同形状、不同节奏的 radial-gradienttransform 动画叠加。调参时优先收束可见范围和对比度,而不是继续增加颜色或动画。

先明确它不是内容,而是环境

聊天页的视觉优先级应该很清晰:用户先看到 Agent 在说什么,再看到输入框和建议,最后才感知页面有一点流动感。

所以云雾层需要满足四个约束:

约束实现选择
不影响交互pointer-events: none
不挤压内容布局position: absolute,不放进正常 flow
永远在内容下方雾层 z-index: 0,内容容器 z-index: 1
动画成本可控只动 transformopacity

页面结构最终很简单:

1
2
3
4
<div className="hq-agent-courtyard">
<div className="hq-courtyard-mist" aria-hidden="true" />
<div className="hq-courtyard-content">{/* 消息、输入框与任务面板 */}</div>
</div>

这里的 aria-hidden 也很重要。云雾没有语义,不应该被屏幕阅读器读到,更不该被当成一个可操作元素。

两朵雾,而不是一张渐变背景

一开始用单层大渐变时,效果很像带颜色的蒙版。它有颜色变化,但没有空间关系。

后来我改成 ::before::after 两层。每层不是一个圆,而是多组椭圆形 radial-gradient 拼出来的非规则云团:左侧留一团偏深的青绿,中部和右侧再放更浅的雾,边缘用很长的透明过渡消掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.hq-courtyard-mist {
position: absolute;
inset: 5%;
z-index: 0;
pointer-events: none;
opacity: 0.82;
}

.hq-courtyard-mist::before,
.hq-courtyard-mist::after {
content: '';
position: absolute;
inset: 0;
border-radius: 48% 52% 57% 43% / 44% 49% 51% 56%;
filter: blur(12px);
will-change: transform, opacity;
}

.hq-courtyard-mist::before {
background:
radial-gradient(ellipse 26% 15% at 16% 22%, rgba(93, 171, 153, 0.5), transparent 78%),
radial-gradient(ellipse 21% 28% at 29% 32%, rgba(129, 190, 175, 0.4), transparent 78%),
radial-gradient(ellipse 27% 36% at 65% 72%, rgba(117, 184, 168, 0.5), transparent 76%);
}

真正起作用的不是某一个色值,而是三层关系:

  1. 形状不规则:每个 gradient 的大小和位置不同,避免出现一眼可识别的圆形光斑。
  2. 透明边界足够长:色彩在 76% 到 82% 之间才消失,云团会更像在空气里扩散。
  3. 两层方向相反:一层从左下缓慢移动到右上,另一层反向移动,页面才有轻微的深度感。

动画只负责“活着”,不负责表演

这个页面不是营销首页,云雾不能用大幅位移抢镜。我把两层动画拉成 6 秒和 8 秒,并使用 alternate 往返:不需要额外做首尾拼接,也不会出现明显的瞬间跳回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@keyframes jiangnan-mist-cloud-a {
from {
transform: translate3d(-22%, 10%, 0) scale(0.82);
opacity: 0.36;
}
to {
transform: translate3d(22%, -10%, 0) scale(1.18);
opacity: 0.92;
}
}

@keyframes jiangnan-mist-cloud-b {
from {
transform: translate3d(24%, -12%, 0) scale(1.2);
opacity: 0.84;
}
to {
transform: translate3d(-24%, 12%, 0) scale(0.8);
opacity: 0.3;
}
}

这里我刻意只用了 transformopacity。它们通常可以由 compositor 处理,不会每一帧触发布局计算。相比直接动画化 topleftwidth 或一串渐变参数,这更适合常驻的背景效果。

最重要的一次调整:从“铺满”改成“收束”

第一版我把雾层设成 inset: -16%。直觉是让云团从视口外飘进来,避免边缘被裁切。结果实际看下来,页面整个区域都被雾覆盖:背景有了,但聊天卡片的玻璃质感反而被稀释,输入框周边也不够干净。

最终调整是:

1
2
3
4
5
6
/* 初版:云雾覆盖过大,内容区域缺少留白 */
/* inset: -16%; */

/* 调整后:云团收进安全区,边缘交给页面底色 */
inset: 5%;
opacity: 0.82;

这个改动的收益比继续调色更大:

调整视觉结果
雾层缩到安全区四周恢复呼吸感,卡片边界更清楚
保留多层 gradient中央不再是一张平淡的浅绿底图
降低极亮区域的覆盖面积输入框和欢迎气泡成为稳定焦点
两层保持反向漂移页面依然有“空气在流动”的感觉

这也是我这次最有价值的判断:背景动画的优化,大多数时候不是“做得更多”,而是“让它少出现一点”。

玻璃组件和云雾层如何配合

云雾只是底景,玻璃卡片负责把内容从底景里提出来。欢迎气泡和输入框用了半透明背景、细边框、内高光和 backdrop-filter。这样背景雾会隐约透进组件,但文本所在区域仍有足够对比度。

可以把它理解成三层:

1
2
3
底色与页面纹理
-> 缓慢漂移的云雾 layer
-> 玻璃卡片与真实内容

如果没有中间那层云雾,玻璃很容易显得只是半透明白色;如果没有最上层的玻璃容器,雾又会让文字显得发灰。它们应当一起设计,而不是各自加一个效果。

我会怎样验证这类背景动画

这类效果很容易陷入“截图看着不错”的错觉,我通常按下面三件事检查:

  1. 停在任意时刻都成立:截取动画中的任意一帧,内容层级都清楚,不能依赖某个刚好干净的瞬间。
  2. 连续看 10 秒不烦:如果能明确察觉云团在左右移动,节奏已经太强;正确的感觉应该是视线离开后才意识到页面在呼吸。
  3. 性能面板没有持续压力:确认动画只涉及合成友好的属性,并在低性能设备上观察掉帧和 GPU 占用。

此外,生产页面还应该补一条 prefers-reduced-motion,让用户可以在系统层面关闭或静止这类环境动画。审美不能覆盖可访问性偏好。

要不要用 / 我的判断框架

  • 值得上:空状态、Agent 对话页、工作台欢迎页这类内容稀疏但需要建立产品情绪的界面。用低对比、慢节奏的环境层补足空间。
  • 可以再等:数据密集的列表、表格和监控台。那里信息密度已经够高,背景动画多半只会制造噪声。
  • 重点关注:优先控制雾层范围、透明度和内容对比度,再讨论颜色和动画曲线。层级错了,再漂亮的 gradient 也是干扰。

从前端转向 Agent 和 AI Infra 后,我越来越在意这类“非功能性”的交互细节。Agent 的能力决定它能做什么,界面的节奏决定用户是否愿意把注意力交给它。云雾当然不会让模型更聪明,但它可以让等待、输入和阅读这几个瞬间更安静一些。