☰
液体按钮效果全解析:SVG滤镜、Canvas元球与CSS实现方案
2026/10/17 10:15:23 网站建设 项目流程

第一次注意到液体按钮(Liquid button),是在一组灵感社区的动效作品里。鼠标还没点下去,按钮边缘就开始像被加热的糖浆一样往外渗,松开时又带着果冻般的弹性收回来。当时我的第一反应和大多数人一样:这玩意儿浏览器里做得出来?后来花了两三个周末把主流的几种实现方式都跑通了,也因为这个效果调参数熬了好几个晚上。

液体按钮不是一个新出来的控件库,也不是某个框架的组件,它本质上是一套交互反馈的视觉语言。核心特征在于:按钮的边缘不再是一条硬边,而是像液滴一样可以流动、粘连、回弹;多个元素靠近时,交界处会短暂地出现一层“液桥”,就像两个气泡在合并前那一瞬间的状态。对前端开发者来说,它是从CSS过渡到滤镜、Canvas物理模拟的一条极好的练习路径;对设计师来说,它是动效叙事里经常被点名的灵感方向。这篇文章我会把三类主流实现方案都拆开讲清楚,并给出可以直接抄去用的代码和参数。

1. 先看懂“液体感”到底是怎么骗过眼睛的

1.1 三个视觉要素:柔边、粘连、回弹

想把液体按钮做好,先得理解“液体感”的本质不是画一堆水滴形状,而是靠三件事骗过视觉系统。

第一是柔边。正常的按钮边界是锐利的,而液体边缘是连续渐变的柔和过渡,就像墨水滴进水里散开的浓度场。任何液体感的第一步都是把硬边变成软边,技术在实现上通常叫“模糊”。

第二是粘连。两个液体元素靠近到一定程度时,中间会拉出一条细丝或液桥,而不是简单地在某个距离突然碰在一起。这种粘连有一个关键阈值:太早粘会成一坨,太晚粘就像是两个硬球碰撞,完全没有“牵丝”的粘稠感。

第三是回弹。液体按钮的回弹不是匀速也不是简单缓动,而是带阻尼的振荡。用生活经验来理解:按下一块果冻,松手后它会先弹过原位,再回缩一点,然后再稳定下来。这种过冲和回摆就是液体感和普通按钮最大的区别。

1.2 先泼三盆冷水

在进入技术方案之前,我想先纠正三个很容易遇到的认知偏差,这几个坑我都在项目里踩过。

  • 液体按钮不等于圆形按钮。做一个圆角很大的胶囊按钮,那不叫液体按钮。没有融合、没有液桥、没有动态回弹,它只是一个圆润的形状。液体按钮的核心是“动态关系”,不是静态外观。
  • 不是必须用WebGL。很多人一开始以为这么细腻的效果得上Shader,实际上SVG滤镜和Canvas完全能覆盖九成需求,WebGL只有在做大规模粒子流体时才值得搬出来。
  • 好看不等于堆叠多层效果。我见过有同学把液体、粒子、辉光、玻璃拟态全叠在一个按钮上,结果页面打开先卡两秒。液体感有个特点就是它本身已经很抢眼,一个页面有一个这样的交互就足够了,后面我还会展开说这个问题。

2. 三大技术路线:SVG滤镜、Canvas元球、纯CSS伪装

2.1 SVG滤镜:一条滤镜走天下的经典方案

SVG滤镜方案是目前实现gooey(粘稠)效果最经典的做法,核心就两个元素:feGaussianBlur负责把按钮边缘糊开,feColorMatrix负责把糊开的半透明区域重新“收紧”成硬边。这两步一前一后,恰好实现了上一节说的“柔边”和“粘连”。

这套方案的本质思路是:把多个按钮元素放在同一个父容器里,在父容器上挂一个滤镜。当几个元素离得近时,模糊阶段它们的边缘会自然叠加变浓,阈值化阶段叠加部分的透明度超过阈值,于是被保留成一体;而离得远的部分透明度不够,就被清掉了。一进一出之间,液桥就出来了。

优点非常明显:实现成本极低,几条滤镜记录就搞定,而且渲染是在浏览器GPU上完成的,不需要自己算任何物理。缺点是它更适合“元素之间按预定路径靠近”的静态融合场景,很难响应鼠标逐帧追踪的自由拖拽。

2.2 Canvas元球:要自由就要物理

如果需求是拖着一个圆到处跑,靠近主按钮时自然粘连,那SVG滤镜就有点吃力了。这时候该用Canvas的方案,它的核心是一种叫Metaball(元球)的算法。

元球的思路可以理解成:屏幕上不是一个一个的圆,而是一张“浓度场”。每个圆都会向周围散布浓度,离圆心越近浓度越高,离得越远浓度越低。画面最终显示的是“浓度大于某个值”的区域。两个圆靠近时,它们中间的低浓度区会被双方的浓度叠加顶上来,于是视觉上出现一座“桥”,这就是液桥的数学来源。

优点是可操控性极强,位置、半径、浓度衰减方式全都可以动态算,适合做游戏化交互。缺点是需要自己处理性能和像素精度,细节比SVG方案多一个量级。

2.3 纯CSS:能看但别较真

也有纯CSS的替代做法,原理是用多层box-shadow模拟涟漪扩散,或者用border-radius的过渡模拟形态变化。这类方案在按钮尺寸固定、交互只有按压反馈的场景下效果还算过得去,但一旦涉及两个元素融合、液桥拉伸、拖拽跟随,就完全使不上劲了。

我的建议很直接:纯CSS方案只适合做一个“类似液体按压”的轻反馈,比如按钮往下按时外圈泛起一圈扩散阴影。想做真正的Liquid button,老老实实从SVG滤镜或Canvas元球里选。

2.4 方案对比速查表

对比维度SVG滤镜Canvas元球纯CSS伪装
液体质感上限高,边缘细腻高,可控性强低,只有扩散阴影
开发成本低,一条滤镜搞定中高,需要写像素级逻辑最低
性能风险中,模糊范围大时吃GPU高,逐像素计算易掉帧低
交互自由度低,适合固定路径融合高,支持拖拽与实时追踪极低
维护成本低中,参数藏得深低
推荐场景悬浮菜单、按钮动效游戏化拖拽、抽奖转盘简单按压反馈

3. 实操一:SVG滤镜方案做一个悬浮液体菜单按钮

3.1 搭建最基础的HTML骨架

我先拿一个最常见的场景开刀:页面右下角有一个圆形主按钮,鼠标悬浮上去后,旁边弹出一个小圆形菜单,两者之间要有明显的液体粘连感。

HTML结构如下,注意SVG滤镜不需要插入到可见区域,宽度高度设为0即可:

<div class="liquid-wrap"> <button class="liquid-main">+</button> <div class="liquid-menu"></div> </div> <svg width="0" height="0" aria-hidden="true"> <defs> <filter id="goo"> <feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur" /> <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -10" result="goo" /> </filter> </defs> </svg>

这段代码里最关键的是feColorMatrix的第四行:0 0 0 24 -10。它的含义是:新的透明度 =24 × 原来的透明度 - 10。当透明度低于0.42时会被直接压成0,高于0.42时会被顶到接近1。这个操作就是“阈值化”,它让模糊之后的柔和边缘重新变成硬边,但合并区域又要比普通边缘更“浓”,所以能保留液桥。

CSS部分有一个非常容易忽略的细节,必须给.liquid-wrap加上filter: url(#goo),并且配合isolation: isolate:

.liquid-wrap { position: fixed; right: 40px; bottom: 40px; filter: url(#goo); isolation: isolate; } .liquid-main, .liquid-menu { position: absolute; width: 56px; height: 56px; border-radius: 50%; background: #1f2933; } .liquid-main { left: 0; top: 0; z-index: 2; border: none; color: #fff; font-size: 24px; cursor: pointer; } .liquid-menu { left: 0; top: 0; opacity: 0; transform: scale(0) translate(0, 0); transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease; }

isolation: isolate很重要。它会把.liquid-wrap变成一个独立的层叠上下文,防止滤镜把整个页面上其他元素也拖进来一起模糊。不加这个属性,页面其他部分偶尔会被“误伤”出奇怪的边缘。

3.2 滤镜参数的手感区间

stdDeviation这个参数决定模糊半径,手感区间一般在4到10之间。数值太小,液桥短而脆;数值太大,边缘出现浓重的半透明“污渍感”。我自己的习惯是先设8看整体,再根据按钮尺寸缩放。

feColorMatrix的阈值矩阵同样需要慢慢试。矩阵里的24 -10并不是唯一解,它需要和stdDeviation联动调整。如果模糊值大,阈值可以适当拉高,比如28 -12;如果模糊值小,阈值要降一点,比如20 -8,否则边缘会缺像素。

这里有一个直接体验的方法:把feColorMatrix注释掉,只保留模糊,你会看到按钮变成了一团“墨迹”。这一步能帮你判断模糊半径是否合适,然后再把阈值加回来,看它是否恢复成一个锐利但有粘连的边界。调整时反复切换这两步,手感很快会建立起来。

3.3 菜单弹出的动画与节奏

给.liquid-menu加一个打开状态,主按钮悬浮时把菜单从底部弹出来:

.liquid-wrap:hover .liquid-menu, .liquid-wrap:focus-within .liquid-menu { opacity: 1; transform: scale(1) translate(-76px, -76px); }

关键在过渡函数:cubic-bezier(0.34, 1.56, 0.64, 1)。这个曲线的特点是后段会超过1,产生“弹过头再回缩”的效果。这正是液体回弹感的来源。你把它和普通ease-out放在一起对比就会明白,前者看起来像物体被拉了一下,后者只是平平地飞过去。

JavaScript负责处理键盘和触屏的适配,这里用pointerenter和pointerleave,避免鼠标和触屏事件打架:

const wrap = document.querySelector('.liquid-wrap'); const mainBtn = document.querySelector('.liquid-main'); mainBtn.addEventListener('pointerenter', () => { wrap.classList.add('is-open'); }); mainBtn.addEventListener('pointerleave', () => { wrap.classList.remove('is-open'); }); mainBtn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); wrap.classList.toggle('is-open'); } });

触屏上pointerleave的触发时机和鼠标不太一样,建议把打开和关闭的逻辑拆成独立函数,在需要时手动调用,不要依赖单一的hover状态。

3.4 SVG滤镜方案的三个落地教训

  • 边缘缺像素问题。阈值矩阵会把一部分半透明边缘直接清成完全透明,造成按钮视觉上比实际尺寸小一圈。解决方式是在.liquid-wrap的背景层加一个同色底的圆,这样缺的边缘会被底色补上,液桥却不受影响。具体的做法是给.liquid-wrap设置background: #f5f7fa,和页面底色一致,或者用一个与按钮同色的大圆垫在底层。
  • 滤镜里不要放文字。很多同学直接把按钮文字放在液体容器内部,结果打开滤镜后文字边缘也被模糊和阈值化,变得又粗又脏。正确的做法是:液体层纯粹是色块,文字或图标放在容器外部独立层,位置用绝对定位对齐。
  • 注意高分辨率屏幕的模糊半径。在Retina屏幕上,同样的stdDeviation看起来会偏小,因为物理像素密度更高。建议在媒体查询里对高分屏适当加大模糊值,或者在Canvas渲染时直接乘上devicePixelRatio。

4. 实操二:Canvas元球方案做可拖拽的液体交互

4.1 元球算法的核心公式

Canvas方案绕不开元球(Metaball)这个概念,它的计算公式不复杂,但理解它背后的思想对调参很有帮助。

想象平面上有若干个圆,每个圆都向周围辐射一种“浓度值”。对于屏幕上任意一个像素点,它对每个圆的贡献都可以用一个简单的分式来近似:

concentration = r^2 / ((x - cx)^2 + (y - cy)^2)

其中r是圆的半径,(cx, cy)是圆心坐标,(x, y)是当前像素坐标。把所有圆对这个像素的贡献加起来,如果总和大于等于1,这个像素就是“液体的一部分”;小于1,就保持透明。

为什么是r^2除以距离平方?因为浓度随距离衰减的速度要和面积匹配。如果你把圆的半径加倍,它的影响范围会扩大四倍,这个平方关系恰好补偿了面积变化,让阈值的尺度保持一致。用生活经验类比:闻到一个气味源,离它1米时的气味浓度,大约是离它2米时的四倍,而不是两倍。

4.2 低分辨率渲染管线的完整实现

朴素地逐像素遍历整张画布,在1080p分辨率下光是循环就要跑两百万次,性能会非常难看。我用的方案是“低分辨率计算 + 放大绘制”,具体分三步。

第一步,建一个尺寸只有显示画布三分之一的小画布,所有的元球计算都在这个小画布上做。第二步,在小画布上用getImageData逐像素算浓度并写入数据,相当于生成一张低分辨率的“液体掩膜”。第三步,把这张低分辨率掩膜用drawImage放大回主画布,放大后边缘会有锯齿,再用一次轻微模糊掩盖。

这里给出核心的绘制函数,为了可读性我做了简化:

function drawMetaBalls(lowCtx, balls, threshold = 1.0) { const w = lowCtx.canvas.width; const h = lowCtx.canvas.height; const img = lowCtx.getImageData(0, 0, w, h); const data = img.data; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { let sum = 0; for (let i = 0; i < balls.length; i++) { const b = balls[i]; const dx = x - b.x; const dy = y - b.y; const distSq = dx * dx + dy * dy; if (distSq < 0.01) { sum = Infinity; break; } sum += (b.r * b.r) / distSq; } const alpha = Math.min(255, Math.max(0, (sum - threshold) * 120)); const offset = (y * w + x) * 4; data[offset] = 40; data[offset + 1] = 60; data[offset + 2] = 80; data[offset + 3] = alpha; } } lowCtx.putImageData(img, 0, 0); }

注意我用了(sum - threshold) * 120来平滑过渡alpha。如果直接写成sum >= threshold ? 255 : 0,放大时锯齿会非常明显。用一个缩放系数把阈值附近的渐变保留下来,视觉柔和度会好很多,这个系数就是液体边缘“软硬”的调节旋钮。

4.3 给球加上位移和吸附逻辑

元球算法本身只管“怎么画”,不管“球怎么动”。液体按钮的另一个核心是球与球之间的吸附和回弹。我在项目里的做法是给每个球维护位置、速度和加速度三个向量,每帧按弹簧模型更新。

function springAttract(a, b, restDist, stiffness, damping) { const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (dist - restDist) * stiffness; a.vx += (dx / dist) * force; a.vy += (dy / dist) * force; a.vx *= damping; a.vy *= damping; a.x += a.vx; a.y += a.vy; }

两个参数值得记一下:stiffness控制吸附力强弱,0.1左右手感很软,适合大半径缓慢靠近;0.2以上会显得比较“弹”。damping是阻尼系数,0.8附近有轻微过冲,0.9几乎无回弹,0.7回弹明显但可能会晃几下才稳定。液体按钮适合的阻尼在0.75到0.85之间,既保证液体感又不会晃得让人头晕。

吸附的触发距离一般是两球半径之和的1.2倍。小于这个距离时液桥开始出现,小于0.8倍时两球基本变成一个整体。这个比例直接控制液桥的“粗细”,我建议把它做成一个可拖动的调试滑块,肉眼观察最直观。

4.4 把性能顶上去的三板斧

  • 低分辨率不是越低越好。三倍缩放是一个平衡点,再低边缘会糊成一团,再高计算的像素点还是太多。
  • 合并粒子绘制。当两球距离特别近时,可以先画一个覆盖两者的外接大圆,再画两个小圆,减少元球函数的计算次数。视觉效果几乎一样。
  • 动态帧率。页面静止时没必要每帧都跑元球计算,可以化用requestAnimationFrame的时间戳判断:如果所有球的位移都小于0.5像素,就暂停到下一次事件触发再继续。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象原因解决办法
按钮边缘出现一圈半透明“脏边”模糊半径过大或阈值太低减小stdDeviation,或把矩阵改为24 -10以上
液桥过粗,糊成一团阈值太低,两个元素过早粘连提高阈值偏移量,例如24 -12
Canvas方案边缘锯齿明显低分辨率放大后没做平滑放大绘制后加一次filter: blur(2px),或提高平滑缩放系数
拖拽时卡顿逐像素计算量过大降低低分辨率画布尺寸,缩小遍历范围
某些WebKit浏览器滤镜不生效对CSSfilter: url(#)支持不完整把滤镜直接放到SVG内部,或在WebKit下降级为预渲染的液桥图片
按钮上文字变糊文字被放进液体容器层文字层和液体层分开,用绝对定位对齐

5.2 一个典型的“液桥消失”排查过程

有一次我在做H5活动页时,悬浮菜单在桌面端一切正常,挪到某些低端Android机器上,液桥完全消失,两个圆变成了各自独立的硬边球。

我最初怀疑是滤镜兼容问题,查了一圈发现并不是。后来定位到是阈值问题:低端机的GPU渲染精度差异导致模糊阶段的实际透明度低于矩阵阈值,融合区域被阈值清掉了。最终方案是把feGaussianBlur的stdDeviation从8降到6,同时把阈值矩阵的偏移从-10改成-8,牺牲了一点液桥厚度换来了跨设备的稳定性。这类问题在真机测试前很难发现,所以涉及商业活动页尽量提前准备好参数降级方案。

5.3 一份参数速查备忘

  • SVG滤镜:stdDeviation建议4~10,阈值矩阵建议在20 -8到28 -12之间调节
  • Canvas元球:平滑缩放系数建议80~140,吸附触发距离为半径和1.2倍,弹簧刚度0.12~0.18,阻尼0.78~0.85
  • 动效时长:液体扩散300~500ms,回弹360~600ms,过长会拖沓

6. 场景边界与落地建议

6.1 液体按钮适合出现在什么地方

从我的实际项目经验来看,液体按钮最适合三类场景。

第一类是品牌官网的形象主按钮。一个首页只有一两个核心操作,液体效果会成为整个页面的记忆点。用户会为了“再看一眼那个按钮”而多停留几秒。

第二类是活动类H5的触发按钮。比如抽奖转盘中央的“立即开奖”,用户点击的瞬间按钮融化扩散,配合结果弹出,仪式感直接拉满。这类按钮是“一次性沉浸式互动”,液体效果能有效强化情绪。

第三类是游戏化任务模块的入口。在打卡、签到场景里,液体按钮的果冻回弹会让人产生一种“点下去有反馈、完成有奖励”的心理暗示,比普通按钮更鼓励用户操作。

6.2 哪些地方千万别硬上

高频操作区域不要放液体按钮。表单提交、聊天发送、支付确认这些需要立即感知“已触发”的交互,液体动效带来的延迟感会造成误操作和焦虑。

低端设备覆盖量大的业务核心流程也要谨慎。液体按钮的渲染开销一般比普通按钮高一个量级,如果目标用户里中低端机占比高,建议只做简化版回弹,不要做全屏液滴扩散。

无障碍方面同样要注意。液体按钮的可点击区域辨识度比普通按钮低,视觉障碍用户很难感知边界,这时一定要配合aria-label和清晰的状态提示,不能把核心操作完全寄托在动效上。

6.3 落地时的一些实际经验

动效时间控制在600毫秒以内。超过了就会从“顺手”变成“拖沓”,用户会开始不耐烦。

永远准备一个降级方案。最简单的做法是在根元素上加一个no-liquid类,当系统开启“减少动态效果”偏好时,或者检测到设备性能不足时,直接移除滤镜层,让按钮退回普通状态,成本极低但能在关键时刻保住体验底线。

先实现静态形状,再叠加液体,最后才加物理模拟。这是我一直推荐的顺序。如果你一开始就奔着物理模拟去,调试的变量太多,很容易被参数淹没。先把形状和布局搞定,再一步步叠效果,每加一层就重新跑一遍全设备测试。

我在自己的项目里还有一个习惯:做液体按钮的时候,一定单独开一个调试面板,把模糊半径、阈值、弹簧刚度、阻尼这些关键参数暴露成滑块。液体效果是很感性的事情,一个参数差0.02,手感差出一个级别,而这种差异靠肉眼远胜于靠计算。你花十分钟搭的调试面板,能帮你省下后面至少一整天的猜测时间。

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

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

立即咨询