---
name: liquid-button-hover
description: Use when you want "Playful, creative, energetic" - Animates button backgrounds like liquid filling or flowing.
---

# Liquid Button Hover

> **Category:** Motion / Interaction  -  **Personality:** Playful, creative, energetic
>
> **Best use:** Creative landing pages, campaigns
>
> **Ratings:** Professional 3/5 - Casual 5/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A button that, instead of just changing colour on hover, looks like it **fills with liquid**. A coloured layer rises from the bottom of the pill and the front edge behaves like a surface — bubbling blobs that merge gooily, an undulating wave crest, a splash that floods out from the cursor, or carbonated fizz. It is pure decoration over a normal `<button>`, so the click target and label never move. The personality is playful and energetic, which is why it suits drinks brands, launch campaigns and creative landing pages rather than serious dashboards.

The demo ("GULP" soda campaign) shows **four interchangeable fill styles** driven by the same recipe: a clipped pill + an absolutely-positioned fill layer whose `height` (or `transform: scale`) animates on `:hover` / `:focus-visible`.

## Dependencies / CDN

**None - pure CSS** for the fill itself, plus one tiny inline `<svg>` `<filter>` for the gooey-metaball variant (no network request, no library). The "splash from cursor" variant adds ~12 lines of **vanilla JS** to set the splash origin; everything else works with CSS alone.

Optional display/body fonts used by the demo:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Hanken+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

One inline goo filter (once per page), then a button per fill style. Every button is `text` in a `.lq-label` sitting **above** the fill layer; per-button colour is passed as CSS variables.

```html
<!-- define the gooey-merge filter ONCE, anywhere in the page -->
<svg width="0" height="0" aria-hidden="true"><defs>
  <filter id="lq-goo" color-interpolation-filters="sRGB">
    <feGaussianBlur in="SourceGraphic" stdDeviation="6" 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 19 -8"/>
  </filter>
</defs></svg>

<!-- A · gooey metaball fill -->
<button class="lq-btn lq-goo" style="--fill:#ff8a3d;--glow:rgba(255,138,61,.55)">
  <span class="lq-goo-layer">
    <span class="lq-goo-base"></span>
    <i class="lq-blob"></i><i class="lq-blob"></i><i class="lq-blob"></i><i class="lq-blob"></i>
  </span>
  <span class="lq-label">Pour me one</span>
</button>

<!-- B · wave fill -->
<button class="lq-btn lq-wave" style="--fill:#36d6ff;--glow:rgba(54,214,255,.5)">
  <span class="lq-wave-fill">
    <svg class="lq-crest" viewBox="0 0 240 24" preserveAspectRatio="none" aria-hidden="true">
      <path d="M0 16 Q15 6 30 16 T60 16 T90 16 T120 16 T150 16 T180 16 T210 16 T240 16 V24 H0 Z"/>
    </svg>
  </span>
  <span class="lq-label">Ride the wave</span>
</button>

<!-- C · splash from cursor (needs the JS below) -->
<button class="lq-btn lq-splash" style="--fill:#b6ff3d;--glow:rgba(182,255,61,.5)">
  <span class="lq-splash-ink"></span>
  <span class="lq-label">Make a splash</span>
</button>

<!-- D · fizz fill -->
<button class="lq-btn lq-fizz" style="--fill:#ff3d8b;--glow:rgba(255,61,139,.5)">
  <span class="lq-fizz-fill"></span>
  <span class="lq-bubbles"><i></i><i></i><i></i><i></i><i></i><i></i></span>
  <span class="lq-label">Crack one open</span>
</button>
```

## CSS

```css
/* shared shell — a clipped pill; the label rides above the fill layer */
.lq-btn{position:relative;isolation:isolate;overflow:hidden;cursor:pointer;border:0;
  font-weight:600;font-size:17px;padding:15px 30px;border-radius:999px;
  color:#fff4ea;background:rgba(255,244,234,.06);
  box-shadow:inset 0 0 0 1.5px rgba(255,244,234,.24);
  transition:transform .35s cubic-bezier(.34,1.5,.64,1),box-shadow .4s}
.lq-btn .lq-label{position:relative;z-index:2;white-space:nowrap;transition:color .35s}
.lq-btn:hover,.lq-btn:focus-visible{transform:translateY(-3px);
  box-shadow:inset 0 0 0 1.5px rgba(255,244,234,0),0 18px 36px -16px var(--glow,rgba(0,0,0,.6))}
.lq-btn:hover .lq-label,.lq-btn:focus-visible .lq-label{color:var(--ink,#1c0a16)}
.lq-btn:focus-visible{outline:2px solid #fff4ea;outline-offset:3px}

/* A · gooey metaball fill — blobs rise and merge through the goo filter */
.lq-goo-layer{position:absolute;inset:0;z-index:0;filter:url(#lq-goo);pointer-events:none}
.lq-goo-base{position:absolute;left:-12%;width:124%;bottom:0;height:0;background:var(--fill);
  transition:height .5s cubic-bezier(.5,.05,.2,1)}
.lq-blob{position:absolute;bottom:-4px;border-radius:50%;background:var(--fill);
  transform:translateY(130%);transition:transform .55s cubic-bezier(.34,1.3,.5,1)}
.lq-blob:nth-of-type(1){left:6%;width:30px;height:30px}
.lq-blob:nth-of-type(2){left:30%;width:46px;height:46px}
.lq-blob:nth-of-type(3){left:56%;width:28px;height:28px}
.lq-blob:nth-of-type(4){left:76%;width:40px;height:40px}
.lq-goo:hover .lq-goo-base{height:140%}
.lq-goo:hover .lq-blob{transform:translateY(-65%)}
.lq-goo:hover .lq-blob:nth-of-type(2){transition-delay:.05s}
.lq-goo:hover .lq-blob:nth-of-type(3){transition-delay:.1s}
.lq-goo:hover .lq-blob:nth-of-type(4){transition-delay:.03s}

/* B · wave fill — liquid rises, an SVG crest scrolls across the surface */
.lq-wave-fill{position:absolute;left:0;bottom:0;width:100%;height:0;background:var(--fill);
  transition:height .55s cubic-bezier(.5,.05,.2,1);z-index:0;pointer-events:none}
.lq-crest{position:absolute;left:0;top:-12px;width:200%;height:18px;opacity:0;transition:opacity .2s}
.lq-crest path{fill:var(--fill)}
.lq-wave:hover .lq-wave-fill{height:140%}
.lq-wave:hover .lq-crest{opacity:1;animation:lq-crest 1.25s linear infinite}
@keyframes lq-crest{to{transform:translateX(-50%)}}   /* -50% of 200% = one seamless period */

/* C · splash from cursor — a circle scaled up from the --x/--y entry point */
.lq-splash-ink{position:absolute;top:var(--y,50%);left:var(--x,50%);width:30em;height:30em;
  margin:-15em 0 0 -15em;border-radius:50%;background:var(--fill);transform:scale(0);
  transition:transform .5s cubic-bezier(.22,.61,.36,1);z-index:0;pointer-events:none}
.lq-splash:hover .lq-splash-ink{transform:scale(1)}

/* D · fizz fill — rising liquid + carbonation bubbles */
.lq-fizz-fill{position:absolute;left:0;bottom:0;width:100%;height:0;background:var(--fill);
  transition:height .55s cubic-bezier(.5,.05,.2,1);z-index:0;pointer-events:none}
.lq-bubbles{position:absolute;inset:0;z-index:1;pointer-events:none;border-radius:inherit;overflow:hidden}
.lq-bubbles i{position:absolute;bottom:-14px;border-radius:50%;background:rgba(255,255,255,.6);opacity:0}
.lq-bubbles i:nth-of-type(1){left:13%;width:8px;height:8px}   /* …vary left/size per bubble… */
.lq-fizz:hover .lq-bubbles i{animation:lq-bubble 1.6s ease-in infinite}
.lq-fizz:hover .lq-bubbles i:nth-of-type(3){animation-duration:1.4s;animation-delay:.2s}
@keyframes lq-bubble{0%{transform:translateY(0) scale(.5);opacity:0}
  18%{opacity:.85}80%{opacity:.7}100%{transform:translateY(-3.6em) scale(1);opacity:0}}

/* prefers-reduced-motion → a clean, instant colour fill (no blobs / wave / bubbles) */
@media(prefers-reduced-motion:reduce){
  .lq-blob,.lq-crest,.lq-bubbles{display:none}
  .lq-goo:hover .lq-goo-base,.lq-wave:hover .lq-wave-fill,.lq-fizz:hover .lq-fizz-fill{height:140%}
  .lq-splash:hover .lq-splash-ink{transform:scale(1)}
}
```

## JavaScript

Only the **splash** variant needs JS — to flood from wherever the pointer touches the button (it falls back to a centred fill on keyboard focus or reduced-motion):

```js
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('.lq-splash').forEach(function(btn){
  var ink = btn.querySelector('.lq-splash-ink');
  if(!ink) return;
  function origin(e){
    if(reduce) return;                       // keep the centred simple fill
    var r = btn.getBoundingClientRect();
    ink.style.setProperty('--x', (e.clientX - r.left) + 'px');
    ink.style.setProperty('--y', (e.clientY - r.top)  + 'px');
  }
  btn.addEventListener('pointerenter', origin);   // flood from entry point
  btn.addEventListener('pointerleave', origin);   // drain toward exit point
});
```

## How it works

- **The fill is a separate layer, not the button background.** The pill is `position:relative; overflow:hidden; isolation:isolate`. A coloured layer sits at `z-index:0`, the `.lq-label` at `z-index:2`. `overflow:hidden` + `border-radius` clip everything to the pill, so the liquid can be bigger than the button and still look contained.
- **"Rising liquid" = animating `height`.** `.lq-…-fill` is pinned to the bottom (`bottom:0; height:0`) and transitions to `height:140%` on hover. Going past 100% guarantees the pill fills completely even with rounded corners.
- **Gooey merge (A)** is an SVG filter trick: `feGaussianBlur` smears the blobs together, then `feColorMatrix` cranks the **alpha** (`…0 0 0 19 -8`) to re-sharpen the edge — overlapping blurs cross the alpha threshold and weld into one metaball surface. Solid `--fill` keeps the merged colour clean.
- **Wave (B)** is a 200%-wide SVG path of repeating sine periods translated `-50%` on a linear loop; `-50%` of a 2× element equals exactly one period, so it scrolls seamlessly. It rides at `top:-12px` of the rising fill, so the crest tracks the surface.
- **Splash (C)** positions a big circle's centre at `--x/--y` (the cursor point) and animates `transform: scale(0 → 1)`, so the colour blooms outward from where you touched.
- **Fizz (D)** rises the fill and runs staggered bubble keyframes (`translateY` up + fade) for carbonation.
- **Label legibility flips on fill:** the label transitions to a dark `--ink` as the bright liquid arrives.

## Customization

- **Colour:** set `--fill` (the liquid), `--glow` (hover shadow tint) and optionally `--ink` (label colour once filled) per button — no new CSS needed.
- **Speed / feel:** tune the `height`/`transform` `transition` duration; the springy `cubic-bezier(.34,1.3,.5,1)` on blobs is what makes them *bob* — lower the overshoot for a calmer pour.
- **Gooeyness:** raise `feGaussianBlur stdDeviation` (6 → 10) for fatter, slower-merging blobs; the `feColorMatrix` alpha multiplier (the `19`) controls how hard the edge snaps.
- **Wave character:** edit the path control points (`Q15 6 …`) for taller/choppier crests; change `lq-crest` duration for current speed.
- **Density:** add/remove `.lq-blob` or `.lq-bubbles i` elements (and their `nth-of-type` positions) — keep counts low for performance.

## Accessibility & performance

- **Keyboard parity:** every state also triggers on `:focus-visible`, and a visible `outline` is kept — the effect never replaces the focus ring.
- **Reduced motion:** a `@media (prefers-reduced-motion: reduce)` block hides the blobs, wave and bubbles and lets the layer become a plain instant colour fill; the JS also short-circuits so the splash stays centred. Honour it — bubbling/scrolling motion is exactly what motion-sensitive users opt out of.
- **Contrast:** verify the label's `--ink` against each `--fill` (WCAG AA). Bright juices want a dark ink; deep fills want a light one.
- **Cheap to run:** only `transform`, `opacity` and `height` animate, all on hover only (nothing loops at rest). The goo `filter` is the one GPU cost — apply it to a single small layer, never to a continuously-animating full-screen element.

## Gotchas

- **The goo filter must be in the DOM and `overflow:hidden` must clip it.** `filter:url(#lq-goo)` blurs *outside* the blobs' box; without the pill's `overflow:hidden` you get a fuzzy halo bleeding past the corners.
- **One `#lq-goo` per page.** Duplicate filter `id`s collide; define it once and reuse it for every gooey button.
- **Premultiplied dark fringe.** Blurring a colour over transparency can darken the merge edge; `color-interpolation-filters="sRGB"` (set on the `<filter>`) and a solid `--fill` keep it clean — avoid gradients inside the goo layer.
- **`feColorMatrix` needs whitespace-separated values** (not commas) and the alpha row is what does the work — get the `19 -8` wrong and the fill turns invisible or muddy.
- **Fill must exceed 100%.** `height:100%` leaves unfilled slivers at rounded corners under fast transitions; use `~140%`.
- **Splash size vs. button.** The ink circle (`30em`) must be large enough that scaling to `1` still covers the farthest corner from any entry point — size it generously, it's clipped anyway.
- **`isolation:isolate`** on the button keeps the `z-index` layering (fill `0` / bubbles `1` / label `2`) local, so it can't fight the page's stacking context.
