☰
液体按钮实现指南:从CSS动画到Canvas粒子系统的完整方案
2026/10/14 8:11:03 网站建设 项目流程

1. 会动的按钮不稀奇,但“像水一样”的按钮是另一回事

1.1 液体按钮是什么,解决的到底是什么问题

做前端这些年,我经手过不少奇奇怪怪的界面需求。有一个组件给我的印象特别深,就是液体按钮。说它是按钮,其实更像在按钮外面包了一层“水做的皮肤”:悬停时表面泛起水波,按下时颜色像墨水一样晕开,松开时又像拉扯的果冻慢慢回弹。第一次听到这个词的朋友,可能会觉得它是个花架子,但真正用过之后你会发现,它在“反馈温度”这件事上比传统按钮强得多。

按钮本质上是交互触发的容器,点击、悬停、按压、加载、成功,这五种状态几乎出现在每一种产品里。传统按钮的策略是离散的:hover变个颜色,按下变深一点,loading转个圈。离散状态当然没问题,但当你需要表达“动作在发生”“数据在流动”“操作成功了”这种连续语义时,离散反馈就会显得干巴巴的。

液体按钮解决的就是这个体验缺口。它把水的物理特性——扩散、回弹、溅射、表面张力——作为状态反馈的“渲染方式”:

  • 悬停时,液体做轻微起伏,表示“这里有交互点”;
  • 按下时,液体被挤压扁一点,模拟真实物体的弹性形变;
  • 加载时,液体像在容器里注水,水位线一圈一圈上涨;
  • 完成时,液体表面闪过一圈涟漪,表示成功。

这些反馈并不是炫技,而是把操作的结果物理化。用户不需要阅读文案,只看水的形态就能理解当前状态。这在很多即时反馈产品里非常实用。

我见过不少团队把液体按钮直接理解成“按钮上加一圈水波动画”,这是不对的。水波只是液体按钮的“皮”,真正的东西是那套状态反馈逻辑。你按下它,它像水一样回应你的力度;你松手,它又能以水的自然节奏恢复平静。这种连续、可感知的反馈逻辑,才是液体按钮和普通按钮拉开差距的核心。

1.2 什么场景真的适合上液体按钮

我说实话,不是所有项目都适合用液体按钮。如果是一个后台管理系统,核心诉求是信息密度和操作效率,那用液体按钮就是画蛇添足。但下面这几类场景,用液体按钮的收益是肉眼可见的。

第一类是游戏化、教育类产品。这类产品的交互天然带着“玩”的属性,按钮的反馈越像真实物体,孩子的注意力就越容易停留在任务上。我之前接的一个早期教育应用,底部导航的悬停按钮就用了类似水波的微交互,小朋友点击率明显比之前的普通按压高了不少。

第二类是工具类产品里的“关键操作”。比如一个录音按钮、一个发送按钮、一个“立即开启”按钮。这些操作往往是单一、低频、仪式感强的动作,用水滴充填的进度反馈,比一个抽象的转圈图标更能传递“正在发生”的感觉。我做过一个语音输入工具,长按录音时按钮内部水位持续上涨,用户自然就知道“我正在录入,不要松手”,这个认知几乎是零成本的。

第三类是面向演示、营销、活动页面的主按钮。这类页面只需要一两个主视觉按钮,追求的是风格统一和视觉记忆。液体按钮的水质感和品牌调性结合之后,印象分通常比普通按钮高很多。我记得有个活动页的主按钮从普通渐变改成了液态悬浮质感之后,页面停留时长涨了差不多一倍,当然这和整体视觉也有关系,但按钮确实成了用户愿意多看一眼的锚点。

当然也有不适用的情况。如果你的产品要支持弱网环境、老年模式或强无障碍要求,液态动画会明显增加功耗和阅读干扰,这种时候宁可放弃效果,也不要牺牲基础可用性。我自己现在的判断标准很简单:看用户在这个按钮上的操作频率。如果一天要点几十次,那就老老实实用普通按钮;如果一次操作是整个流程的高光时刻,液体按钮就值得上。

2. 三种实现路线:CSS、SVG、Canvas,怎么选才不会翻车

在真正动手之前,我习惯先把方案选型过一遍。液体按钮的实现路线,大方向上可以分成三类:纯 CSS、SVG、Canvas。它们的视觉效果、性能成本和代码复杂度差距非常大,选错路真的会返工,所以这个章节值得细看。

2.1 纯 CSS 路线:性价比最高的“液体平面”

所谓“液体平面”,就是不做真正的物理模拟,而是用 CSS 的过渡和变形,把“水的感觉”演出来。

最常见的手法组合是这样的:

  • border-radius 做大数值过渡,让按钮在圆形和圆角矩形之间平滑变形;
  • transform scale 模拟挤压和回弹;
  • 伪元素结合 radial-gradient 或 linear-gradient 做水波纹理和光照质感;
  • clip-path 或 overflow hidden 做水面溢出和裁剪效果;
  • mix-blend-mode 配合背景色,做出水中透光的感觉。

这套做法的优点是性能极好,哪怕在低端安卓机上也能流畅跑,因为手机上绝大部分特效都走了 GPU 合成。缺点也很明显:它不能做真正的粒子飞溅,状态之间的过渡比较“演”,更多是“看起来像水”,经不起细看。

我举个最常见的水波按钮例子。悬停时产生一圈向外扩散的波纹,用纯 CSS 就能实现。

HTML 结构:

<button class="wave-btn">开始连接</button>

CSS 核心:

.wave-btn { position: relative; overflow: hidden; padding: 12px 28px; border: none; border-radius: 40px; background: #4f6ef7; color: #fff; cursor: pointer; } .wave-btn::before { content: ''; position: absolute; left: 50%; top: 50%; width: 0; height: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.35); transform: translate(-50%, -50%); transition: width 0.5s ease-out, height 0.5s ease-out; } .wave-btn:hover::before { width: 200px; height: 200px; }

这里的关键,是把波纹从“永远渲染的装饰”改成“只在 hover 时生长的伪元素”。伪元素初始宽高为 0,hover 时扩大到 200px,同时透明度渐渐消失。因为按钮 overflow:hidden,波纹不会溢出边界。注意单独用 transition 不能同时控制宽高和透明度衰减,所以更精细的做法是配一个 keyframes 动画,让透明度在结束前主动归零。

这一版“演”的痕迹比较明显,但如果只是悬停和按压反馈,完全够用。纯 CSS 方案的另一个好用场景是“液态胶囊导航”,几个按钮排成一行,选中态像水珠一样从当前项滑到下一项,底部用一条流动的渐变线暗示水迹。这种效果放在活动页和官网导航上,观感很高级,成本却很低。

2.2 SVG 路线:让形态变形更可控

如果你想进一步控制液体的形态,推荐用 SVG 做状态路径的插值。SVG 的优势在于路径是精确的矢量数据,你可以把按钮的液态形状定义成一条可随时替换的贝塞尔路径。

具体做法有两种主流玩法。

第一种是替换 path 的 d 属性。把按钮的轮廓从“圆角矩形”切换成“水滴”或“膨胀液滴”,在路径之间做动画过渡。高端交互库里常见的“液态导航”就用的是这个方案,底层的原理其实是多段贝塞尔曲线的插值。你可以把按钮轮廓拆成上下左右四段曲线,每段曲线都由几个控制点决定,拖动时控制点按水的形态走位,视觉上按钮边线就会像液体一样起伏。

第二种是用 SVG 滤镜。给按钮增加 feTurbulence 和 feDisplacementMap,可以在按钮边缘产生一点轻微抖动,看起来就像水面在震动。我之前在一个活动页上试过,给主按钮套一层 displacement 滤镜,配合缓慢循环的动画,按钮边缘会一直有“水在做布朗运动”的感觉。这个效果用 CSS 几乎做不出来,但 SVG 只需要十几行滤镜代码。

简单示例:

<svg class="liquid-svg" width="200" height="60"> <defs> <filter id="liquidDistort"> <feTurbulence type="fractalNoise" baseFrequency="0.015" numOctaves="2" seed="3" result="noise" /> <feDisplacementMap in="SourceGraphic" in2="noise" scale="12" xChannelSelector="R" yChannelSelector="G" /> </filter> </defs> <rect x="10" y="10" width="180" height="40" rx="20" fill="#4f6ef7" filter="url(#liquidDistort)" /> </svg>

这种方式配置少、效果直接,但对性能的消耗比纯 CSS 大,尤其在高分辨率屏幕上连续执行滤镜计算时会有明显发热。所以它更适合放在低频次、短时长的视觉反馈里,不适合作为常驻动画。另外,SVG 滤镜的渲染结果在不同浏览器里不完全一致,Safari 和 Chrome 的观感会有微妙差别,这点后面我会专门讲。

2.3 Canvas 路线:从“看起来像水”到“真的会流动”

如果你要的是真正的粒子飞溅、重力回弹、阻尼形变,那还是要老老实实上 Canvas。

Canvas 路线通常包含一块透明画布,画布覆盖在按钮上方,粒子系统负责模拟水分子:粒子在按钮内部聚合,拖拽时向外扩散,松手后受重力和阻尼影响归位。听起来很高端,做起来也确实比前两种方案复杂,但视觉效果是最接近“真水”的。

这一段的难点在于,水的感觉不是单靠形变就能骗过眼睛的,它需要满足三个物理直觉:

  • 惯性:粒子被拖拽后不会立刻停下,会有一段滑行;
  • 合力:粒子之间要保持弱引力,让它们聚在一起,又不至于粘死;
  • 阻尼:运动过程中要持续损耗能量,否则水永远在晃,用户会头晕。

我将在下一节的实操中给出具体实现,这里先说明逻辑:Canvas 方案适合承载“需要拖拽、有实体感”的液体按钮,比如可拖动完成的操作、下拉式的液态验证、带能量回弹的发送按钮。如果你的项目里根本没有拖拽交互,只是 hover 和 click,那上 Canvas 纯属给自己找麻烦。

2.4 选型对比

方案性能实现难度视觉上限适用场景
纯 CSS极高低中等悬停波纹、按压形变、常规按钮微交互
SVG中高中较高液态变形导航、边缘扰动的拟水效果
Canvas中低高最高拖拽流体、粒子溅射、重力回弹

我的建议是:如果只是给现有项目锦上添花,优先纯 CSS;如果视觉想要更高级的边缘扰动,就上 SVG 滤镜;如果功能本身就需要按钮承载“拖拽”交互,那直接 Canvas。最怕的就是明明只要一个 hover 反馈,却选了 Canvas 方案,最后卡得用户骂娘。

3. 实操过程:从“水波按钮”到“液态按钮”的完整实现

3.1 整体结构设计和状态定义

不管用哪种技术路线,我建议先把液体按钮当做一个“带状态的小系统”来设计,而不是直接堆代码。

我习惯先明确五个状态:idle、hover、pressed、progress、success。

  • idle:静止,水面平静,边缘微光;
  • hover:水波微动,暗示可交互;
  • pressed:液体被挤压,粒子向两侧扩散,按钮产生弹性形变;
  • progress:液体以注水或旋转涡流方式表现进度;
  • success:表面涟漪一圈,扩散后回到 idle。

这五个状态对应到 DOM 上,可以简单用 class 或><div class="liquid-fill-btn"> <span class="btn-label">按住说话</span> <div class="water-surface"></div> </div>

CSS 核心:

.liquid-fill-btn { position: relative; width: 180px; height: 56px; border-radius: 28px; overflow: hidden; background: #161b2d; cursor: pointer; user-select: none; } .water-surface { position: absolute; left: -20%; width: 140%; height: 100%; bottom: -100%; background: linear-gradient( 180deg, rgba(79, 110, 247, 0.2) 0%, #4f6ef7 100% ); transition: bottom 0.45s cubic-bezier(0.33, 1, 0.68, 1); } .liquid-fill-btn:hover .water-surface, .liquid-fill-btn.is-pressed .water-surface { bottom: 0; }

这里有一个我常踩的坑:water-surface 的 height 不要设为 100%,否则水面升起后会顶到按钮顶边,看起来像个实心块。更自然的做法是让 height 始终略大于容器,并利用伪装的渐变透明度,在贴近容器顶部时产生“水面即将溢出”的感觉。如果你希望水面上有一层波光,再加一个浅色的径向渐变层,让它在水面顶部位置缓慢水平移动即可。

这个版本已经可以应付很多真实场景了。长按录音、下载、订阅这类按钮,基本就是这个形态。

3.3 Canvas 版本:粒子系统与回弹关节

如果你想更进一步,做可拖拽的液态按钮,就要上 Canvas。我把自己调试过的一个最小区块实现分享出来,代码不复杂,但逻辑是完整的,你可以直接抄去改。

先准备画布:

<canvas id="liquidCanvas" width="200" height="80"></canvas> <button id="liquidBtn" class="sr-only-focusable">发送</button>

初始化代码:

const canvas = document.getElementById('liquidCanvas'); const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr);

这一步很关键,很多朋友不处理 dpr,画出来的粒子在 Retina 屏上会发虚。Canvas 的逻辑尺寸和 CSS 布局尺寸也容易搞混,我总是用 getBoundingClientRect 取实际布局尺寸,再把 width 和 height 乘以 dpr,最后整体 scale 一次。这样永远不用操心具体像素值。

粒子系统的核心是两个类:Particle 负责“单滴水”,Simulation 负责“这群水的整体行为”。

class Particle { constructor(x, y) { this.x = x; this.y = y; this.vx = (Math.random() - 0.5) * 6; this.vy = -3 - Math.random() * 4; this.gravity = 0.12; this.damping = 0.98; this.radius = 2 + Math.random() * 2; this.life = 1; this.decay = 0.015; } update() { this.vy += this.gravity; this.vx *= this.damping; this.vy *= this.damping; this.x += this.vx; this.y += this.vy; this.life -= this.decay; } draw() { ctx.globalAlpha = Math.max(this.life, 0); ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fillStyle = '#4f6ef7'; ctx.fill(); ctx.globalAlpha = 1; } }

Particle.update 里的 vx *= damping,代表空气阻力。每帧都会损耗约 2% 的水平速度,这样粒子不会永远滑行。gravity 让粒子最终落回按钮内部。life 和 decay 用来让溅出的粒子逐渐消失,避免屏幕上积累一堆残影。

Simulation 负责把这些粒子约束在按钮范围附近:

class Sim { constructor(canvas, cx, cy) { this.cx = cx; this.cy = cy; this.particles = []; this.pressed = false; } spawn(n) { for (let i = 0; i < n; i++) { this.particles.push(new Particle(this.cx, this.cy)); } } update() { this.particles.forEach(p => p.update()); this.particles.forEach(p => { const dx = p.x - this.cx; const dy = p.y - this.cy; const dist = Math.sqrt(dx * dx + dy * dy); if (dist > 80) { p.vx -= dx * 0.002; p.vy -= dy * 0.002; } }); this.particles = this.particles.filter(p => p.life > 0); } draw() { ctx.clearRect(0, 0, rect.width, rect.height); this.particles.forEach(p => p.draw()); } }

这里的“拽回来”逻辑就是模拟液体的表面张力。超出半径 80px 的粒子,会被一个朝向按钮中心的弱力拉回,但这个力不能太大,太大会让水瞬间凝固成铁块;太小水就会散成一锅粥。0.002 这个系数是我调了很久之后,在“看似松散但不散架”的平衡点上确定的,你可以根据自己的按钮尺寸上下浮动,但一般都在 0.001 到 0.005 之间。

动画主循环:

let sim = new Sim(canvas, 100, 40); function tick() { sim.update(); sim.draw(); requestAnimationFrame(tick); } tick();

当用户点击或拖拽按钮时,调用 sim.spawn(6),往粒子列表里增加六滴“溅水”。这样整个按钮的液体感就出来了。洒出的粒子在重力影响下下落,在阻尼作用下逐渐减慢,视觉上会自动呈现出“水聚回来”的效果。

3.4 把 Canvas 版本接进真实按钮组件

如果你实际项目用的是 React 或 Vue,上面这段 Canvas 逻辑怎么柔和地放进组件?我的习惯是把 Canvas 封装成一个独立组件,只暴露三个方法:press、move、release。按钮的真实点击还是交给底层的 button 元素去处理,Canvas 只负责视觉反馈。

function LiquidButton() { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; // 初始化 sim // 绑定事件 }, []); return ( <div className="liquid-btn-wrap" style={{ position: 'relative' }}> <button className="liquid-btn" onClick={handleClick}> 发送 </button> <canvas ref={canvasRef} className="liquid-canvas-layer" /> </div> ); }

唯一要注意的是,Canvas 层必须把 pointer-events 设为 none,否则事件会被画布拦截,按钮点击失效。这是很多刚上手 Canvas 特效的人最容易犯的错误,我在实战里也见过不止一次。另外,Canvas 层的尺寸要跟随按钮尺寸变化,比如按钮文字变长、padding 调整,画布必须同步 resize,否则液体范围和按钮视觉范围就对不上了。

3.5 性能调优三板斧

Canvas 方案虽然效果好,性能触发点也不少。我总结了三个最常见的优化点。

第一,粒子数量上限必须截断。比如 sim.spawn 之前先判断 this.particles.length > 300 就 return。粒子多到几百上千之后,绘制开销会直线上升,低端机型上很快跌到 30fps 以下。

第二,不要让 clearRect 整个屏幕。如果场景只有按钮这一块区域,可以只 clear 按钮的包围盒。但要注意,如果粒子飞出包围盒,会被误清。更稳妥的做法是 clearRect 的时候多给一点 padding。

第三,不要在 resize 事件里反复重置 canvas 宽高。重置宽高会丢失画布上下文状态,还会触发一整帧重绘。正确做法是用防抖,150ms 内多次 resize 只执行最后一次。

4. 常见问题与排查实录

说句实话,液体按钮这个方向我踩过的坑,比做过的效果还多。这里挑几个典型的故障场景写下来,算是给后面的人提前排雷。

4.1 性能告急:低端机器掉帧到 30fps

曾经有个项目,粒子数量设到 450,在桌面 Chrome 上跑 60fps 毫无压力,结果换到一台两年前的安卓中端机上,拖拽时直接卡成 PPT。原因有三个,一是粒子绘制没有合并,每帧都在逐个 beginPath 和 fill;二是 clearRect 的尺寸太大;三是粒子重力计算里有大量平方根,在低端 CPU 上是实打实的计算压力。

我的处理方式是:粒子数量降到 260,所有粒子先合并成单一 path 再一次性 fill,clearRect 范围限定在按钮尺寸周边,最后把重力计算里的平方根换成了距离平方比较。经过这三板斧,中端机也能稳定在 50fps 以上。

合并绘制的代码片段:

ctx.clearRect(0, 0, rect.width, rect.height); ctx.beginPath(); sim.particles.forEach(p => { ctx.moveTo(p.x + p.radius, p.y); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); }); ctx.fillStyle = '#4f6ef7'; ctx.fill();

这样不仅代码更简洁,每帧只调用一次 fill,对 Canvas 底层驱动友好很多。这是我在实际项目中发现的第一个“看起来不明显但收益巨大”的优化点。

4.2 交互冲突:拖拽按钮时页面跟着滚

液体按钮如果支持拖拽,一定会面临和页面滚动冲突的问题。用户本意是从按钮上滑动解锁,结果手指一歪,页面就跟着滚走了。

解决办法有两个方向。一个是事件层加判断:在 touchstart 时记录初始坐标,如果位移超过阈值(比如 12px)就判定为拖拽,并给外层容器加上 touch-action:none,阻止默认滚动。另一个是按钮自身加一个“操作锁定”状态,一旦进入拖拽判定,就阻止后续事件冒泡。

我更推荐在容器层面用 touch-action 来做,因为触屏设备对 touch-action 的支持比较一致,而且无需手动管理事件锁,代码少很多。唯一要注意的是,不能给这个容器加全局的 touch-action:none,否则普通点击和滚动都会被禁掉,那是灾难。

4.3 可访问性隐患:动画好看,键盘用户看不见

液体按钮如果只是一个 div 加一堆 Canvas,键盘用户完全感知不到它的存在。可访问性隐患很容易被视觉设计师忽略,但一旦上线被投诉,返工成本会很高。

我的习惯是:不管效果多花哨,底层仍然用一个真实的 button 元素。视觉层全都用 CSS 或 Canvas 包在 button 内部或上方。button 保持可聚焦、可 Tab、可 Enter/Space 触发。然后针对动效,用 prefers-reduced-motion 这个媒体查询检测用户是否偏好减少动态效果,如果命中,就自动关闭粒子动画,只保留最简单的颜色变化。

@media (prefers-reduced-motion: reduce) { .liquid-btn .canvas-layer { display: none; } }

这一行看起来不起眼,但对于支持该媒体查询的系统来说,是区分“专业实现”和“Demo 玩具”的重要分水岭。

4.4 兼容性基线:Safari 和低版本浏览器的缺斤短两

SVG 滤镜动画在 Safari 上偶尔会出现边缘抖动突然卡顿的问题,这跟 Safari 的滤镜栅格化机制有关。遇到这种情况,我会给 svg 元素强制加上 will-change: transform,并且让滤镜区域比实际按钮略大一点,防止浏览器裁剪掉滤镜渲染的边缘。

另外,CSS 变量和 clip-path 在部分老旧浏览器上支持不完整。如果产品对老旧浏览器有硬性要求,我一般会做一个能力检测,检测不到就回退到普通按钮样式,至少保证功能和语义完整。把“效果增强”做成渐进增强的姿势,比追求所有设备都一致要省心得多。

5. 个人经验之谈:液体按钮该这么用,也这么收

聊回经验层面,液体按钮这类效果,我刚上手时犯过的最大的错,就是只顾着好看,忽略了“它是按钮”这个前提。后来我给自己定了几条原则。

第一,视觉效果优先级永远低于功能完整性。液体按钮在没有动画的情况下,也必须是一个完美的普通按钮。样式、状态、hover、focus、键盘操作、点击区域都补齐之后,再往上面叠加水感。

第二,动画时长和曲线要克制。液体按钮的每个反馈动作,尽量控制在 300ms 以内。水的回弹可以延长到 450ms,但绝不能超过 500ms,否则用户会觉得操作没完成,烦躁感会飙升。我常用的曲线是 cubic-bezier(0.33, 1, 0.68, 1),先快后慢,符合液体出人意料的“回弹-沉淀”路径。

第三,把所有可调参数集中抽出来。粒子数量、重力系数、回弹力度、水面高度、缓动曲线,最好集中在一个配置文件或 CSS 变量组里。设计师想微调时,不需要翻代码逻辑,改一个数字就行。这听起来是工程洁癖,但在多人协作时,真的能省掉大量来回拉扯的时间。

如果你问我后续还想往哪个方向扩展,我觉得有两个点很有意思。一是把液体按钮和多按钮联动作联动,比如一个液态子按钮拖出来之后,另一个按钮自动让出位置,形成“水流绕行”的感觉;二是把物理模拟从纯粒子升级成流体表面的波动方程模拟,效果会非常细腻,但计算量也更大,需要放到 WebGL 或 WASM 层面做。技术路线没那么简单,但作为业余探索,已经有足够的乐趣了。

回到最初的问题:做一个液体按钮值不值?我的答案是一半看场景,一半看克制。在适合的场景里,它是把反馈体验从“及格”拉向“优秀”的点睛之笔;在不适合的地方硬塞,它就变成了性能负担和可用性灾难。真正让我觉得有趣的,并不是水波效果本身,而是用整整一个小系统的状态逻辑,去让一个按钮“看起来活了起来”。这种把 UI 做成小生命体的思路,是我这几年做前端遇到过最有想象力的设计方向之一。

如果你也在项目里试一下,我建议从最便宜的 CSS 注水方案开始,跑通状态机,再根据自己的需要升级到 Canvas。先做出一个能用的版本,再去追求“真水”的感觉,这条路最稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询