Liquid Button — Free HTML CSS JS Gooey Hover Snippet

Liquid Button · Buttons · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

SVG goo filter
Blur plus alpha contrast fuses overlapping blobs.
Rising metaballs
Nine blobs climb and merge into liquid.
Springy entrance
An overshooting cubic-bezier sells the wobble.
Staggered drain
Blobs recede and drip on exit.
Self-cleaning blobs
Each removes itself on finish.
Crisp label
Text stays above the goo, pointer-events none.
Contained compositing
isolation keeps the filter to the button.
Click confirmation
Label swaps to a success state.

About this UI Snippet

Liquid Button — Gooey Metaball Fill With an SVG Goo Filter

Screenshot of the Liquid Button snippet rendered live

The liquid button is the gooey hover effect where the button's fill rises from the bottom as a cluster of blobs that merge into one organic liquid surface — the classic "metaball" look. This snippet builds it with plain HTML, an SVG filter for the goo, CSS, and vanilla JavaScript using the Web Animations API to launch the blobs.

The SVG goo filter

The magic is a reusable SVG filter, #lqGoo. It blurs the source graphic with feGaussianBlur, then runs the blurred result through a feColorMatrix that cranks the alpha channel's contrast (0 0 0 20 -9 on the alpha row). The high alpha multiply plus the negative bias turns the soft blurred edges into a hard threshold: anywhere the blur is dense enough becomes fully opaque, anywhere it's faint becomes transparent. The effect is that separate blurred circles which overlap fuse into a single smooth blob with organic necks between them — exactly how liquid metaballs merge. Applying filter: url(#lqGoo) to the blob container makes every circle inside it gooey.

Blobs that rise and merge

On pointerenter, the script fires nine blobs in quick succession (35ms apart), each positioned at an even horizontal slot across the button. Each blob animates with the Web Animations API from below the button (translateY(40px) scale(0)) up into place at scale(1.5), using a springy cubic-bezier(.34, 1.4, .5, 1) that overshoots. Because the blobs are large and adjacent, they overlap as they rise — and the goo filter fuses them into one wobbling liquid fill that climbs the button. A solid base fill (the ::before) sits under them so the final state is a clean filled pill.

Draining on exit

On pointerleave, each existing blob animates back down and shrinks to zero on a slight stagger, then removes itself in the onfinish callback. The staggered drain makes the liquid appear to recede and drip away rather than vanishing instantly. Pending entrance timers are cleared first so a quick hover-in-hover-out doesn't leave orphaned blobs mid-flight.

Label above the goo

The text label sits at z-index: 2 with pointer-events: none, above the gooey fill layer, so it stays crisp and readable while the liquid animates behind it. isolation: isolate on the button contains the filter's compositing to the button itself. Clicking swaps the label to a "Subscribed ✓" confirmation for a moment.

Why the Web Animations API

Each blob needs its own timed, self-cleaning animation, and element.animate() returns a handle with an onfinish hook — perfect for fire-and-forget blobs that remove themselves when done. It avoids juggling CSS animation classes and animationend listeners across nine short-lived elements.

Customizing it

Change the blob count and size for a chunkier or finer liquid, tune the stdDeviation and the alpha matrix for more or less gooeyness, recolor the blobs and base fill, or adjust the spring easing. Make it a link by swapping the element. Pair it with a shimmer button or place it in an animated gradient CTA for a lively call to action.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA rounded button renders with a label.
  2. 2
    Hover the buttonGooey blobs rise from the bottom and merge into a liquid fill.
  3. 3
    Leave the buttonThe liquid drains and drips away on a stagger.
  4. 4
    Click itThe label flips to a Subscribed confirmation briefly.
  5. 5
    Tune the gooAdjust the blur and alpha matrix for more or less merge.
  6. 6
    Recolor itChange the blob and base fill colors.

Real-world uses

Common Use Cases

Playful CTAs
A lively button inside an animated gradient CTA.
Newsletter signups
Pair with a newsletter signup form.
Landing heroes
Place under a lamp header title.
Creative portfolios
Match a shimmer button elsewhere.
Pricing actions
The CTA on a pricing card.
Goo filter demos
A reference for SVG metaball merging.

Got questions?

Frequently Asked Questions

The blob container has an SVG goo filter applied: feGaussianBlur softens the circles, then feColorMatrix sharply increases the alpha channel's contrast (multiply 20, bias -9). That thresholds the blur so dense overlaps become fully opaque and faint edges vanish, fusing overlapping circles into a single smooth blob with organic necks — the metaball effect.

On hover, nine blobs are launched 35ms apart at even horizontal slots. Each animates with the Web Animations API from below the button at scale 0 up to scale 1.5 using a springy cubic-bezier(.34,1.4,.5,1) that overshoots. Being large and adjacent, they overlap as they climb, and the goo filter fuses them into one liquid fill.

Each blob is a short-lived element that needs its own timed animation and must remove itself when done. element.animate() returns a handle with an onfinish callback, perfect for fire-and-forget blobs, and avoids managing CSS animation classes and animationend listeners across nine elements that come and go.

Yes. The label sits at z-index 2 with pointer-events: none, above the gooey fill layer, so it remains crisp while the liquid animates behind it. isolation: isolate on the button keeps the filter's compositing contained so it doesn't affect surrounding content.

Include the SVG filter once in your app (it can live in a shared layout). Render the button as a component and trigger the blob animations from pointerenter/pointerleave handlers that append blobs to a ref'd container with element.animate, cleaning them up on finish and clearing timers on unmount. In Tailwind, style the pill with utilities and keep the goo filter and blob logic in the component.

Liquid Button — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Liquid Button</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}
    
    .lq-stage{padding:40px}
    .lq-btn{position:relative;border:none;background:none;padding:16px 36px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:16px;font-weight:800;color:#fff;isolation:isolate}
    .lq-label{position:relative;z-index:2;pointer-events:none}
    
    .lq-blobs{position:absolute;inset:0;z-index:1;border-radius:inherit;filter:url(#lqGoo);overflow:visible}
    .lq-blobs::before{content:'';position:absolute;inset:0;border-radius:inherit;background:#6366f1}
    .lq-blob{position:absolute;top:50%;width:34px;height:34px;border-radius:50%;background:#6366f1;transform:translate(-50%,-50%) scale(0)}
  </style>
</head>
<body>
  <div class="lq-stage">
    <svg width="0" height="0" class="lq-defs"><filter id="lqGoo"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/><feBlend in="SourceGraphic" in2="goo"/></filter></svg>
  
    <button type="button" class="lq-btn" id="lqBtn">
      <span class="lq-blobs" id="lqBlobs"></span>
      <span class="lq-label">Subscribe</span>
    </button>
  </div>
  <script>
    var btn = document.getElementById('lqBtn');
    var host = document.getElementById('lqBlobs');
    
    // On hover, fire a series of gooey blobs that rise from the bottom edge. The SVG
    // goo filter merges any overlapping circles into one organic liquid mass.
    var timers = [];
    function fluidIn() {
      clear();
      var r = btn.getBoundingClientRect();
      var n = 9;
      for (var i = 0; i < n; i++) {
        (function (i) {
          timers.push(setTimeout(function () {
            var b = document.createElement('span');
            b.className = 'lq-blob';
            var x = (i + 0.5) / n * 100;
            b.style.left = x + '%';
            host.appendChild(b);
            b.animate(
              [{ transform: 'translate(-50%,40px) scale(0)' },
               { transform: 'translate(-50%,-50%) scale(1.5)', offset: .6 },
               { transform: 'translate(-50%,-50%) scale(1.5)' }],
              { duration: 600, easing: 'cubic-bezier(.34,1.4,.5,1)', fill: 'forwards' }
            );
          }, i * 35));
        })(i);
      }
    }
    function fluidOut() {
      clear();
      Array.prototype.slice.call(host.querySelectorAll('.lq-blob')).forEach(function (b, i) {
        setTimeout(function () {
          b.animate([{ transform: getComputedStyle(b).transform },
                     { transform: 'translate(-50%,40px) scale(0)' }],
            { duration: 400, easing: 'ease-in', fill: 'forwards' }).onfinish = function () { b.remove(); };
        }, i * 25);
      });
    }
    function clear() { timers.forEach(clearTimeout); timers = []; }
    
    btn.addEventListener('pointerenter', fluidIn);
    btn.addEventListener('pointerleave', fluidOut);
    btn.addEventListener('click', function () {
      var l = btn.querySelector('.lq-label');
      l.textContent = 'Subscribed ✓';
      setTimeout(function () { l.textContent = 'Subscribe'; }, 1400);
    });
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Liquid Button</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh
    }

    .lq-blobs::before {
      content:'';position:absolute;inset:0;border-radius:inherit;background:#6366f1
    }

    .lq-blob {
      position:absolute;top:50%;width:34px;height:34px;border-radius:50%;background:#6366f1;transform:translate(-50%,-50%) scale(0)
    }
  </style>
</head>
<body>
  <div class="p-10">
    <svg width="0" height="0" class="lq-defs"><filter id="lqGoo"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/><feBlend in="SourceGraphic" in2="goo"/></filter></svg>
  
    <button type="button" class="relative border-0 bg-transparent py-4 px-9 rounded-[999px] cursor-pointer font-[inherit] text-base font-extrabold text-[#fff] [isolation:isolate]" id="lqBtn">
      <span class="lq-blobs absolute inset-0 z-[1] rounded-[inherit] [filter:url(#lqGoo)] overflow-visible" id="lqBlobs"></span>
      <span class="lq-label relative z-[2] pointer-events-none">Subscribe</span>
    </button>
  </div>
  <script>
    var btn = document.getElementById('lqBtn');
    var host = document.getElementById('lqBlobs');
    
    // On hover, fire a series of gooey blobs that rise from the bottom edge. The SVG
    // goo filter merges any overlapping circles into one organic liquid mass.
    var timers = [];
    function fluidIn() {
      clear();
      var r = btn.getBoundingClientRect();
      var n = 9;
      for (var i = 0; i < n; i++) {
        (function (i) {
          timers.push(setTimeout(function () {
            var b = document.createElement('span');
            b.className = 'lq-blob';
            var x = (i + 0.5) / n * 100;
            b.style.left = x + '%';
            host.appendChild(b);
            b.animate(
              [{ transform: 'translate(-50%,40px) scale(0)' },
               { transform: 'translate(-50%,-50%) scale(1.5)', offset: .6 },
               { transform: 'translate(-50%,-50%) scale(1.5)' }],
              { duration: 600, easing: 'cubic-bezier(.34,1.4,.5,1)', fill: 'forwards' }
            );
          }, i * 35));
        })(i);
      }
    }
    function fluidOut() {
      clear();
      Array.prototype.slice.call(host.querySelectorAll('.lq-blob')).forEach(function (b, i) {
        setTimeout(function () {
          b.animate([{ transform: getComputedStyle(b).transform },
                     { transform: 'translate(-50%,40px) scale(0)' }],
            { duration: 400, easing: 'ease-in', fill: 'forwards' }).onfinish = function () { b.remove(); };
        }, i * 25);
      });
    }
    function clear() { timers.forEach(clearTimeout); timers = []; }
    
    btn.addEventListener('pointerenter', fluidIn);
    btn.addEventListener('pointerleave', fluidOut);
    btn.addEventListener('click', function () {
      var l = btn.querySelector('.lq-label');
      l.textContent = 'Subscribed ✓';
      setTimeout(function () { l.textContent = 'Subscribe'; }, 1400);
    });
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to LiquidButton.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}

.lq-stage{padding:40px}
.lq-btn{position:relative;border:none;background:none;padding:16px 36px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:16px;font-weight:800;color:#fff;isolation:isolate}
.lq-label{position:relative;z-index:2;pointer-events:none}

.lq-blobs{position:absolute;inset:0;z-index:1;border-radius:inherit;filter:url(#lqGoo);overflow:visible}
.lq-blobs::before{content:'';position:absolute;inset:0;border-radius:inherit;background:#6366f1}
.lq-blob{position:absolute;top:50%;width:34px;height:34px;border-radius:50%;background:#6366f1;transform:translate(-50%,-50%) scale(0)}
`;

export default function LiquidButton() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var btn = document.getElementById('lqBtn');
    var host = document.getElementById('lqBlobs');
    
    // On hover, fire a series of gooey blobs that rise from the bottom edge. The SVG
    // goo filter merges any overlapping circles into one organic liquid mass.
    var timers = [];
    function fluidIn() {
      clear();
      var r = btn.getBoundingClientRect();
      var n = 9;
      for (var i = 0; i < n; i++) {
        (function (i) {
          timers.push(setTimeout(function () {
            var b = document.createElement('span');
            b.className = 'lq-blob';
            var x = (i + 0.5) / n * 100;
            b.style.left = x + '%';
            host.appendChild(b);
            b.animate(
              [{ transform: 'translate(-50%,40px) scale(0)' },
               { transform: 'translate(-50%,-50%) scale(1.5)', offset: .6 },
               { transform: 'translate(-50%,-50%) scale(1.5)' }],
              { duration: 600, easing: 'cubic-bezier(.34,1.4,.5,1)', fill: 'forwards' }
            );
          }, i * 35));
        })(i);
      }
    }
    function fluidOut() {
      clear();
      Array.prototype.slice.call(host.querySelectorAll('.lq-blob')).forEach(function (b, i) {
        setTimeout(function () {
          b.animate([{ transform: getComputedStyle(b).transform },
                     { transform: 'translate(-50%,40px) scale(0)' }],
            { duration: 400, easing: 'ease-in', fill: 'forwards' }).onfinish = function () { b.remove(); };
        }, i * 25);
      });
    }
    function clear() { timers.forEach(clearTimeout); timers = []; }
    
    btn.addEventListener('pointerenter', fluidIn);
    btn.addEventListener('pointerleave', fluidOut);
    btn.addEventListener('click', function () {
      var l = btn.querySelector('.lq-label');
      l.textContent = 'Subscribed ✓';
      setTimeout(function () { l.textContent = 'Subscribe'; }, 1400);
    });
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="lq-stage">
        <svg width="0" height="0" className="lq-defs"><filter id="lqGoo"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/><feBlend in="SourceGraphic" in2="goo"/></filter></svg>
      
        <button type="button" className="lq-btn" id="lqBtn">
          <span className="lq-blobs" id="lqBlobs"></span>
          <span className="lq-label">Subscribe</span>
        </button>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function LiquidButton() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var btn = document.getElementById('lqBtn');
    var host = document.getElementById('lqBlobs');
    
    // On hover, fire a series of gooey blobs that rise from the bottom edge. The SVG
    // goo filter merges any overlapping circles into one organic liquid mass.
    var timers = [];
    function fluidIn() {
      clear();
      var r = btn.getBoundingClientRect();
      var n = 9;
      for (var i = 0; i < n; i++) {
        (function (i) {
          timers.push(setTimeout(function () {
            var b = document.createElement('span');
            b.className = 'lq-blob';
            var x = (i + 0.5) / n * 100;
            b.style.left = x + '%';
            host.appendChild(b);
            b.animate(
              [{ transform: 'translate(-50%,40px) scale(0)' },
               { transform: 'translate(-50%,-50%) scale(1.5)', offset: .6 },
               { transform: 'translate(-50%,-50%) scale(1.5)' }],
              { duration: 600, easing: 'cubic-bezier(.34,1.4,.5,1)', fill: 'forwards' }
            );
          }, i * 35));
        })(i);
      }
    }
    function fluidOut() {
      clear();
      Array.prototype.slice.call(host.querySelectorAll('.lq-blob')).forEach(function (b, i) {
        setTimeout(function () {
          b.animate([{ transform: getComputedStyle(b).transform },
                     { transform: 'translate(-50%,40px) scale(0)' }],
            { duration: 400, easing: 'ease-in', fill: 'forwards' }).onfinish = function () { b.remove(); };
        }, i * 25);
      });
    }
    function clear() { timers.forEach(clearTimeout); timers = []; }
    
    btn.addEventListener('pointerenter', fluidIn);
    btn.addEventListener('pointerleave', fluidOut);
    btn.addEventListener('click', function () {
      var l = btn.querySelector('.lq-label');
      l.textContent = 'Subscribed ✓';
      setTimeout(function () { l.textContent = 'Subscribe'; }, 1400);
    });
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh
}

.lq-blobs::before {
  content:'';position:absolute;inset:0;border-radius:inherit;background:#6366f1
}

.lq-blob {
  position:absolute;top:50%;width:34px;height:34px;border-radius:50%;background:#6366f1;transform:translate(-50%,-50%) scale(0)
}
      `}</style>
      <div className="p-10">
        <svg width="0" height="0" className="lq-defs"><filter id="lqGoo"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/><feBlend in="SourceGraphic" in2="goo"/></filter></svg>
      
        <button type="button" className="relative border-0 bg-transparent py-4 px-9 rounded-[999px] cursor-pointer font-[inherit] text-base font-extrabold text-[#fff] [isolation:isolate]" id="lqBtn">
          <span className="lq-blobs absolute inset-0 z-[1] rounded-[inherit] [filter:url(#lqGoo)] overflow-visible" id="lqBlobs"></span>
          <span className="lq-label relative z-[2] pointer-events-none">Subscribe</span>
        </button>
      </div>
    </>
  );
}
Vue
<template>
  <div class="lq-stage">
    <svg width="0" height="0" class="lq-defs"><filter id="lqGoo"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/><feBlend in="SourceGraphic" in2="goo"/></filter></svg>
  
    <button type="button" class="lq-btn" id="lqBtn">
      <span class="lq-blobs" id="lqBlobs"></span>
      <span class="lq-label">Subscribe</span>
    </button>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

let btn;
let host;
// On hover, fire a series of gooey blobs that rise from the bottom edge. The SVG
// goo filter merges any overlapping circles into one organic liquid mass.
var timers = [];
function fluidIn() {
  clear();
  var r = btn.getBoundingClientRect();
  var n = 9;
  for (var i = 0; i < n; i++) {
    (function (i) {
      timers.push(setTimeout(function () {
        var b = document.createElement('span');
        b.className = 'lq-blob';
        var x = (i + 0.5) / n * 100;
        b.style.left = x + '%';
        host.appendChild(b);
        b.animate(
          [{ transform: 'translate(-50%,40px) scale(0)' },
           { transform: 'translate(-50%,-50%) scale(1.5)', offset: .6 },
           { transform: 'translate(-50%,-50%) scale(1.5)' }],
          { duration: 600, easing: 'cubic-bezier(.34,1.4,.5,1)', fill: 'forwards' }
        );
      }, i * 35));
    })(i);
  }
}
function fluidOut() {
  clear();
  Array.prototype.slice.call(host.querySelectorAll('.lq-blob')).forEach(function (b, i) {
    setTimeout(function () {
      b.animate([{ transform: getComputedStyle(b).transform },
                 { transform: 'translate(-50%,40px) scale(0)' }],
        { duration: 400, easing: 'ease-in', fill: 'forwards' }).onfinish = function () { b.remove(); };
    }, i * 25);
  });
}
function clear() { timers.forEach(clearTimeout); timers = []; }

onMounted(() => {
  btn = document.getElementById('lqBtn');
  host = document.getElementById('lqBlobs');
  btn.addEventListener('pointerenter', fluidIn);
  btn.addEventListener('pointerleave', fluidOut);
  btn.addEventListener('click', function () {
    var l = btn.querySelector('.lq-label');
    l.textContent = 'Subscribed ✓';
    setTimeout(function () { l.textContent = 'Subscribe'; }, 1400);
  });
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}

.lq-stage{padding:40px}
.lq-btn{position:relative;border:none;background:none;padding:16px 36px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:16px;font-weight:800;color:#fff;isolation:isolate}
.lq-label{position:relative;z-index:2;pointer-events:none}

.lq-blobs{position:absolute;inset:0;z-index:1;border-radius:inherit;filter:url(#lqGoo);overflow:visible}
.lq-blobs::before{content:'';position:absolute;inset:0;border-radius:inherit;background:#6366f1}
.lq-blob{position:absolute;top:50%;width:34px;height:34px;border-radius:50%;background:#6366f1;transform:translate(-50%,-50%) scale(0)}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-liquid-button',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="lq-stage">
      <svg width="0" height="0" class="lq-defs"><filter id="lqGoo"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo"/><feBlend in="SourceGraphic" in2="goo"/></filter></svg>
    
      <button type="button" class="lq-btn" id="lqBtn">
        <span class="lq-blobs" id="lqBlobs"></span>
        <span class="lq-label">Subscribe</span>
      </button>
    </div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a14;display:flex;justify-content:center;align-items:center;min-height:100vh}
    
    .lq-stage{padding:40px}
    .lq-btn{position:relative;border:none;background:none;padding:16px 36px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:16px;font-weight:800;color:#fff;isolation:isolate}
    .lq-label{position:relative;z-index:2;pointer-events:none}
    
    .lq-blobs{position:absolute;inset:0;z-index:1;border-radius:inherit;filter:url(#lqGoo);overflow:visible}
    .lq-blobs::before{content:'';position:absolute;inset:0;border-radius:inherit;background:#6366f1}
    .lq-blob{position:absolute;top:50%;width:34px;height:34px;border-radius:50%;background:#6366f1;transform:translate(-50%,-50%) scale(0)}
  `]
})
export class LiquidButtonComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var btn = document.getElementById('lqBtn');
    var host = document.getElementById('lqBlobs');
    
    // On hover, fire a series of gooey blobs that rise from the bottom edge. The SVG
    // goo filter merges any overlapping circles into one organic liquid mass.
    var timers = [];
    function fluidIn() {
      clear();
      var r = btn.getBoundingClientRect();
      var n = 9;
      for (var i = 0; i < n; i++) {
        (function (i) {
          timers.push(setTimeout(function () {
            var b = document.createElement('span');
            b.className = 'lq-blob';
            var x = (i + 0.5) / n * 100;
            b.style.left = x + '%';
            host.appendChild(b);
            b.animate(
              [{ transform: 'translate(-50%,40px) scale(0)' },
               { transform: 'translate(-50%,-50%) scale(1.5)', offset: .6 },
               { transform: 'translate(-50%,-50%) scale(1.5)' }],
              { duration: 600, easing: 'cubic-bezier(.34,1.4,.5,1)', fill: 'forwards' }
            );
          }, i * 35));
        })(i);
      }
    }
    function fluidOut() {
      clear();
      Array.prototype.slice.call(host.querySelectorAll('.lq-blob')).forEach(function (b, i) {
        setTimeout(function () {
          b.animate([{ transform: getComputedStyle(b).transform },
                     { transform: 'translate(-50%,40px) scale(0)' }],
            { duration: 400, easing: 'ease-in', fill: 'forwards' }).onfinish = function () { b.remove(); };
        }, i * 25);
      });
    }
    function clear() { timers.forEach(clearTimeout); timers = []; }
    
    btn.addEventListener('pointerenter', fluidIn);
    btn.addEventListener('pointerleave', fluidOut);
    btn.addEventListener('click', function () {
      var l = btn.querySelector('.lq-label');
      l.textContent = 'Subscribed ✓';
      setTimeout(function () { l.textContent = 'Subscribe'; }, 1400);
    });

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { fluidIn, fluidOut, clear });
  }
}