1. 为什么我放弃了SVG和Canvas,回头用纯CSS画波浪
第一次接到"页面底部要有个动态波浪"的需求时,我的第一反应是打开SVG编辑器画路径,或者干脆用Canvas写个正弦函数动画。毕竟这两种方案在社区里已经被验证过无数次,效果稳定、可控性强。但那次项目有个特殊约束:页面需要嵌入到一个第三方内容管理系统的富文本模块里,那个模块对<script>标签和外部资源加载卡得极严,SVG的<path>虽然能内联,但一旦涉及动画就得配合JS或者SMIL,而SMIL在部分移动端浏览器上的支持情况又参差不齐。
被逼到墙角之后,我重新翻出了CSS的border-radius和radial-gradient,试着用纯样式的方式去"骗"出波浪的视觉效果。结果出乎意料——不仅跑通了,而且性能表现比预想中好得多。后来我又陆续在几个不同场景里迭代了这个方案,从最简单的单层波浪到多层叠加、从静态装饰到动态循环,逐渐摸出了一套用纯CSS实现动态波浪的完整思路。
这篇文章就是把这几次实战的经验整理出来。核心关键词是纯CSS、动态波浪、渐变原理和实战应用。我会从最基础的几何原理讲起,解释为什么border-radius能造出波浪形状,然后一步步拆解动态效果的实现方式,最后给出几个可以直接复制到项目里的完整代码方案。适合已经掌握CSS基础、想在不引入额外依赖的前提下给页面增加动态视觉元素的前端开发者,也适合对CSS图形绘制感兴趣、想拓展技术边界的读者。
需要提前说明的是,纯CSS方案有它的能力边界。它不适合做复杂的物理模拟波浪,也不适合需要精确控制每个波峰位置的场景。但在"装饰性动态背景""页面分隔过渡""加载状态指示"这类需求下,它的性价比极高——零依赖、零请求、渲染性能好、代码量小。下面我会把这些边界条件也讲清楚,避免你在不合适的场景里硬套。
2. 波浪的几何本质:圆角与渐变的视觉欺骗
2.1 用border-radius造出单个波峰
波浪在视觉上是什么?本质上是一条连续起伏的曲线。如果把它拆解到最小单元,就是一个个交替出现的波峰和波谷。在CSS的世界里,要画出曲线,最直接的工具就是border-radius。
一个宽高比为2:1的矩形,如果只给左上角和右上角设置50%的圆角,底部保持直角,会得到一个什么形状?答案是:一个顶部圆润、底部平直的"拱形"。这个拱形就是单个波峰的基础形态。
.wave-peak { width: 200px; height: 100px; background: #4a90d9; border-radius: 50% 50% 0 0 / 100% 100% 0 0; }这里border-radius的斜杠语法是关键。斜杠前是水平方向的半径,斜杠后是垂直方向的半径。50% 50% 0 0 / 100% 100% 0 0的意思是:左上角和右上角的水平半径是宽度的50%,垂直半径是高度的100%。这样画出来的拱形,顶部曲线会非常饱满,接近半椭圆。
但单个波峰不够,波浪需要连续起伏。怎么把多个波峰拼在一起?最朴素的做法是用多个元素并排,但这样代码冗余且不好维护。更好的思路是:用一个元素,通过radial-gradient在水平方向重复绘制多个圆形,利用圆形的弧线来形成波浪的起伏。
2.2 radial-gradient的重复绘制逻辑
radial-gradient默认是从中心向外辐射的圆形或椭圆形渐变。如果设置background-size让渐变在水平方向重复,就能得到一排等距排列的圆形。
.wave-pattern { width: 100%; height: 120px; background: radial-gradient(circle at 50% 100%, #4a90d9 30%, transparent 31%); background-size: 200px 120px; background-repeat: repeat-x; }这段代码的效果是:在高度120px的容器底部,每隔200px画一个半径为60px(120px的50%)的蓝色圆形。圆形的下半部分被容器裁掉,上半部分露出来,形成一个个弧形的"波峰"。但这样画出来的是分离的圆形,波峰之间有空隙,不像连续的波浪。
要让它连续,需要调整圆形的半径和间距,让相邻圆形的弧线在视觉上衔接起来。当圆形的半径等于间距的一半时,相邻圆形刚好相切,弧线连成一条连续的波浪线。这就是波浪效果的核心几何原理。
2.3 多层渐变叠加制造立体感
单层波浪看起来比较扁平。真实的水面波浪有前后层次,前面的波浪颜色深、后面的波浪颜色浅,叠加在一起才有纵深感。用CSS实现多层波浪,只需要在一个容器里叠加多个radial-gradient,每层设置不同的颜色、透明度和偏移量。
.multi-wave { width: 100%; height: 150px; background: radial-gradient(circle at 50% 100%, rgba(74, 144, 217, 0.3) 40%, transparent 41%) 0 0 / 300px 150px repeat-x, radial-gradient(circle at 50% 100%, rgba(74, 144, 217, 0.5) 35%, transparent 36%) 50px 20px / 250px 150px repeat-x, radial-gradient(circle at 50% 100%, #4a90d9 30%, transparent 31%) 100px 40px / 200px 150px repeat-x; }这里有三层波浪,从后到前分别是30%透明度、50%透明度和完全不透明。每层的background-size不同,意味着波峰间距不同;每层的background-position有偏移,意味着波峰位置错开。三层叠加之后,视觉上就有了前后错落的立体感。
注意:
radial-gradient中的circle at 50% 100%表示圆心位于容器底部中央。如果改成ellipse,可以画出更扁平的波浪,适合宽屏场景。
3. 让波浪动起来:位移、缩放与时间函数的选择
3.1 水平位移是最自然的动态方案
静态波浪已经有了,接下来要让它动。最符合直觉的动态方式是让波浪水平移动——就像水面在流动。实现思路很简单:用@keyframes改变background-position的X轴值,让渐变图案在水平方向平移。
@keyframes wave-move { from { background-position: 0 0, 50px 20px, 100px 40px; } to { background-position: 200px 0, 250px 20px, 300px 40px; } } .multi-wave { animation: wave-move 4s linear infinite; }这里有个关键细节:位移的距离必须等于background-size的宽度,或者其整数倍。否则动画循环时会出现"跳帧"——波浪移动到终点后突然跳回起点,视觉上不连贯。上面代码中,第一层的background-size是300px,位移距离是200px,这就不对。正确的做法是让位移距离等于300px,这样动画结束时的图案和开始时完全重合,循环才无缝。
@keyframes wave-move { from { background-position: 0 0, 50px 20px, 100px 40px; } to { background-position: 300px 0, 350px 20px, 400px 40px; } }3.2 垂直方向的浮动增加呼吸感
纯水平移动有时候显得机械。如果给波浪加上轻微的垂直浮动,会有一种"水面呼吸"的感觉。实现方式是在水平位移的基础上,叠加一个垂直方向的translateY动画。但注意,background-position的Y轴变化和transform: translateY的视觉效果不同:前者改变的是渐变图案在容器内的位置,后者改变的是整个容器在页面上的位置。
如果波浪是作为页面背景的一部分,用background-position的Y轴变化更合适,因为它不会影响布局流。如果波浪是一个独立的装饰元素,用transform: translateY性能更好,因为transform不会触发重排。
@keyframes wave-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }3.3 时间函数与动画时长的调参经验
linear是最常用的时间函数,适合匀速流动的波浪。但如果想要更自然的"潮汐感",可以试试ease-in-out,让波浪在两端减速、中间加速。不过要注意,ease-in-out在循环动画中会导致速度不连续——每次循环结束时速度降为零,下一次循环开始时速度从零重新加速,视觉上会有"一顿一顿"的感觉。
我的经验是:单层波浪用linear最稳;多层波浪中,可以让不同层使用不同的时间函数和时长,制造出前后层速度不一致的效果,反而更接近真实水面的复杂运动。
| 层级 | 动画时长 | 时间函数 | 位移距离 | 效果描述 |
|---|---|---|---|---|
| 后层 | 6s | linear | 300px | 缓慢流动,作为背景 |
| 中层 | 4s | ease-in-out | 250px | 中等速度,有轻微节奏变化 |
| 前层 | 3s | linear | 200px | 快速流动,视觉焦点 |
提示:动画时长不要设置得太短。低于2秒的循环会让波浪看起来焦躁不安,失去水面的自然感。3到6秒是比较舒适的区间。
4. 从Demo到生产:实际项目中的适配与优化
4.1 响应式场景下的尺寸适配
在真实项目里,波浪容器很少是固定宽度的。它可能是全屏宽度的页面底部装饰,也可能是卡片内部的局部元素。用固定像素值写background-size在响应式场景下会出问题——宽屏上波浪被拉伸变形,窄屏上波浪密集重叠。
解决方案是使用相对单位。background-size可以用百分比,但百分比是相对于容器尺寸计算的,而radial-gradient的圆形半径又依赖于background-size,这就形成了一个循环依赖。我的做法是用vw单位配合clamp()函数,让波浪尺寸随视口宽度平滑变化,同时设置上下限。
.wave-container { --wave-size: clamp(150px, 20vw, 300px); --wave-height: clamp(80px, 10vw, 150px); height: var(--wave-height); background: radial-gradient(circle at 50% 100%, #4a90d9 30%, transparent 31%); background-size: var(--wave-size) var(--wave-height); background-repeat: repeat-x; }这样在手机窄屏上,波浪尺寸不会小于150px,保证波峰清晰可见;在宽屏显示器上,波浪尺寸不会超过300px,避免波峰过于稀疏。
4.2 性能考量:哪些属性该动,哪些不该动
CSS动画的性能瓶颈通常在于触发重排和重绘的属性。background-position的变化会触发重绘,但不会触发重排,性能开销中等。transform和opacity只触发合成,性能最好。如果波浪动画导致页面滚动卡顿,优先考虑用transform替代background-position。
具体做法是:把波浪图案绘制在一个伪元素上,然后用transform: translateX()移动这个伪元素。由于伪元素超出了父容器的边界,需要给父容器设置overflow: hidden来裁掉多余部分。
.wave-wrapper { position: relative; overflow: hidden; height: 150px; } .wave-wrapper::before { content: ''; position: absolute; inset: 0; width: 200%; background: radial-gradient(circle at 50% 100%, #4a90d9 30%, transparent 31%); background-size: 200px 150px; background-repeat: repeat-x; animation: wave-slide 4s linear infinite; } @keyframes wave-slide { from { transform: translateX(0); } to { transform: translateX(-200px); } }这里伪元素的宽度是父容器的200%,位移距离是200px(等于background-size的宽度)。由于伪元素足够宽,位移过程中不会露出空白区域。
4.3 深色模式与主题色的动态切换
现代项目通常需要支持深色模式。波浪的颜色如果写死,在深色背景下会显得刺眼。用CSS变量来管理波浪颜色是最优雅的方案。
:root { --wave-color: #4a90d9; --wave-opacity: 1; } @media (prefers-color-scheme: dark) { :root { --wave-color: #2c5f8a; --wave-opacity: 0.7; } } .wave-container { background: radial-gradient( circle at 50% 100%, rgba(74, 144, 217, var(--wave-opacity)) 30%, transparent 31% ); }但这里有个坑:radial-gradient中的颜色值如果要用CSS变量控制透明度,不能直接把十六进制颜色和变量拼在一起。需要用rgba()或者color-mix()函数。color-mix()在现代浏览器中支持度已经不错,但如果你需要兼容旧版浏览器,还是用rgba()更稳妥。
5. 三个可以直接抄作业的完整方案
5.1 极简单层波浪:适合加载状态和进度指示
这个方案代码量最少,适合嵌入到按钮、进度条或者加载动画中。核心思路是用一个窄条容器,底部绘制单层波浪,通过水平位移制造流动感。
.loading-wave { width: 100%; height: 40px; background: radial-gradient(circle at 50% 100%, #4a90d9 40%, transparent 41%) 0 0 / 80px 40px repeat-x; animation: loading-wave-move 1.5s linear infinite; } @keyframes loading-wave-move { from { background-position: 0 0; } to { background-position: 80px 0; } }这个方案的波浪间距是80px,波峰高度约16px(40px的40%)。动画时长1.5秒,节奏轻快,适合表示"正在进行中"的状态。你可以把#4a90d9替换成任何主题色,也可以调整background-size的宽度来改变波浪密度。
5.2 三层叠加波浪:适合页面底部装饰和Hero区域
这个方案是我在多个落地页项目里用过的,效果最均衡。三层波浪分别用不同的颜色深度、波峰间距和动画速度,叠加出丰富的层次感。
.hero-wave { position: relative; width: 100%; height: 200px; overflow: hidden; background: linear-gradient(to bottom, transparent 0%, #f0f7ff 100%); } .hero-wave::before, .hero-wave::after { content: ''; position: absolute; bottom: 0; left: 0; width: 200%; height: 100%; background-repeat: repeat-x; } .hero-wave::before { background-image: radial-gradient( ellipse at 50% 100%, rgba(74, 144, 217, 0.25) 45%, transparent 46% ); background-size: 400px 200px; animation: hero-wave-back 8s linear infinite; } .hero-wave::after { background-image: radial-gradient( ellipse at 50% 100%, rgba(74, 144, 217, 0.6) 35%, transparent 36% ); background-size: 300px 200px; animation: hero-wave-front 5s linear infinite; } @keyframes hero-wave-back { from { transform: translateX(0); } to { transform: translateX(-400px); } } @keyframes hero-wave-front { from { transform: translateX(0); } to { transform: translateX(-300px); } }这里用了ellipse而不是circle,因为ellipse可以画出更扁平的波浪,适合宽屏的Hero区域。后层波浪透明度25%、间距400px、动画8秒;前层波浪透明度60%、间距300px、动画5秒。两层叠加后,视觉上会有明显的纵深感。
注意:伪元素的
width: 200%是必须的。如果只设100%,位移到一半时右侧会露出空白。200%的宽度配合等于background-size宽度的位移距离,可以保证整个动画周期内画面始终被波浪覆盖。
5.3 交互式波浪:鼠标悬停时改变流速
这个方案在装饰的基础上增加了交互反馈。当用户鼠标悬停在波浪区域时,动画速度加快,波浪颜色加深,给人一种"水面被搅动"的感觉。
.interactive-wave { width: 100%; height: 120px; background: radial-gradient(circle at 50% 100%, #4a90d9 35%, transparent 36%) 0 0 / 150px 120px repeat-x; animation: interactive-move 4s linear infinite; transition: filter 0.3s ease; } .interactive-wave:hover { animation-duration: 1.5s; filter: brightness(1.2) saturate(1.3); } @keyframes interactive-move { from { background-position: 0 0; } to { background-position: 150px 0; } }这里用filter来改变颜色,而不是直接改background,因为filter的过渡动画更平滑,而且不会触发重绘整个背景。animation-duration的变化在悬停时是瞬间生效的,不会平滑过渡,这是CSS动画的一个限制。如果你需要速度也平滑变化,得用JS配合CSS变量来实现,那就超出"纯CSS"的范畴了。
6. 踩过的坑和几条硬核经验
第一个坑是关于background-repeat的。如果你只写了repeat-x,但容器高度大于background-size的高度,Y轴方向不会重复,底部会露出空白。正确的做法是确保background-size的高度等于或大于容器高度,或者同时设置repeat让两个方向都重复。我一般会把background-size的高度设成和容器高度一致,这样最可控。
第二个坑是关于radial-gradient的圆心位置。circle at 50% 100%表示圆心在容器底部中央,这样画出来的圆形只有上半部分露出来,形成波峰。如果你不小心写成了circle at 50% 50%,圆心在容器中央,画出来的是一个完整的圆形,波浪效果就没了。这个参数是波浪效果的关键,一定要确认清楚。
第三个坑是关于动画循环的接缝。前面提过,位移距离必须等于background-size宽度的整数倍。但如果你用了多层波浪,每层的background-size不同,位移距离也要分别对应。我见过有人把所有层的位移距离都设成同一个值,结果只有一层是无缝的,其他层都有跳帧。正确的做法是每层单独写@keyframes,或者用CSS变量分别控制。
第四个坑是关于性能的。在低端安卓机上,如果波浪容器很大(比如全屏宽度的Hero区域),同时叠加三层以上的radial-gradient,滚动时可能会出现掉帧。我的优化经验是:减少渐变层数到两层,用transform替代background-position,给波浪容器加上will-change: transform提示浏览器提前合成。如果还是卡,就降低波浪的刷新频率——把动画时长拉长,或者用steps()函数让波浪以离散的帧步进,减少渲染压力。
最后一个经验是关于可访问性的。动态波浪对大多数用户来说是装饰性的,但对光敏感的用户可能会感到不适。建议在prefers-reduced-motion媒体查询中关闭动画,只保留静态波浪。
@media (prefers-reduced-motion: reduce) { .wave-container { animation: none; } }这个细节很多教程不会提,但在实际项目中,它是专业度的体现。静态波浪同样有视觉价值,只是少了流动感,对大多数场景来说完全可以接受。
6.1 关于浏览器兼容性的实测数据
我在几个主流浏览器上实测了这套方案的表现。radial-gradient的circle at语法在Chrome 26+、Firefox 16+、Safari 6.1+上都支持,覆盖范围已经很广。background-size配合repeat-x的兼容性更好,基本没有遇到问题。唯一需要注意的是clamp()函数,它在Chrome 79+、Firefox 75+、Safari 13.1+上支持,如果你需要兼容更老的浏览器,得用min()和max()嵌套,或者直接写媒体查询。
prefers-color-scheme和prefers-reduced-motion这两个媒体查询在旧版浏览器上会被忽略,但不会导致错误——不支持的时候,浏览器会直接跳过这些规则,使用默认样式。所以可以放心写,不需要做特性检测。
6.2 一个容易被忽略的细节:波浪的"相位差"
多层波浪叠加时,如果所有层的波峰都在同一个水平位置上,视觉上会显得很假——就像三张同样的图片叠在一起。真实的波浪,前后层的波峰是错开的。实现相位差的方法很简单:给每层的background-position设置不同的初始X轴偏移。
.wave-layer-1 { background-position: 0 0; } .wave-layer-2 { background-position: 50px 0; } .wave-layer-3 { background-position: 120px 0; }偏移量不需要很精确,大致错开就行。我的经验是,相邻层的偏移量控制在background-size宽度的20%到40%之间,视觉效果最自然。偏移太小看不出层次,偏移太大又显得脱节。
这个细节在静态波浪里不太明显,但一旦动起来,相位差的影响就很大。没有相位差的波浪动画看起来像一整块布在平移,有相位差的才像真正的水面在流动。