EmpireUI
Get Pro
← Blog8 min read#liquid#button#animation

Liquid Fill Button Animation in CSS: SVG and clip-path Morph

Build a liquid fill button effect with pure CSS clip-path morphing and SVG feTurbulence — no canvas, no JS libraries, just browser-native animation.

Abstract colorful liquid shapes morphing on dark background

Why Liquid Buttons Are Worth the Trouble

A liquid fill button is one of those micro-interactions that looks complicated but earns disproportionate attention. Hover over it and the fill rises like water flooding a glass. It's tactile in a way flat color-swaps just aren't. Users actually notice it, which in 2026 — when every SaaS landing page looks identical — is a genuine advantage.

The techniques you'll use here — clip-path animation and SVG filter morphology — are both GPU-composited when done right, so you're not trading performance for flair. That said, there's a difference between a lazy implementation that triggers layout thrash on every frame and a clean one that only touches clip-path and transform. We're doing the latter.

Honestly, most tutorials I've seen stop at a CSS ::before pseudo-element that slides up with transform: translateY. That works and it's fine. But it looks mechanical. The real liquid effect needs a wobbly, organic edge — and that's where SVG filters come in. Two different approaches ahead: one pure CSS, one SVG-powered.

The clip-path Wave Approach

clip-path with a polygon() or better yet a path() value is your first tool. The idea: you animate a fill layer from a flat bottom edge to a flat top edge, but you deform the leading edge into a wave curve using path() keyframes. Browser support for animating path() inside clip-path landed in Chrome 88 and Firefox 112 — good enough for production in 2026.

Here's the core structure. The button itself is position: relative; overflow: hidden. A ::before pseudo carries the fill color. You animate its clip-path from a flat polygon at the bottom to a wave that sweeps through and finally exits as a flat polygon at the top. The key is matching the SVG path point count between keyframes — browsers can only interpolate if the path complexity is identical.

.liquid-btn {
  position: relative;
  padding: 14px 36px;
  border: 2px solid currentColor;
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  font-size: 1rem;
  color: #6c47ff;
  background: transparent;
  transition: color 0.4s ease;
  isolation: isolate;
}

.liquid-btn::before {
  content: '';
  position: absolute;
  inset: 0;
  background: #6c47ff;
  /* start below the button */
  clip-path: path('M0,100 C40,100 60,100 100,100 L100,100 L0,100 Z');
  transition: clip-path 0.55s cubic-bezier(0.4, 0, 0.2, 1);
  z-index: -1;
}

.liquid-btn:hover::before {
  /* wave sweeps up and exits at top */
  clip-path: path('M0,-5 C30,10 70,-10 100,-5 L100,100 L0,100 Z');
}

.liquid-btn:hover {
  color: #ffffff;
}

That gets you a tilted wave edge, which is already better than a flat translateY. Worth noting: the isolation: isolate on the button container is not optional — without it, the -1 z-index on ::before can punch through into parent stacking contexts and the fill disappears. I've wasted 20 minutes debugging that exact issue before.

To get a more convincing wave shape you need more control points. Go from a path with 2 cubic bezier curves to one with 4-5, all anchored along the x-axis. The path needs to cover the full width — if your button is 200px wide you should express coordinates in percentage terms via path() or use a viewBox inside an inline SVG approach instead (covered in the next section).

The SVG feTurbulence Blob Effect

The wobbling edge you see in high-end liquid effects — the kind where the fill looks like actual water with surface tension — isn't achievable with static clip-path alone. You need feTurbulence + feDisplacementMap piped through a CSS filter reference. This is where the SVG filter graph earns its complexity.

The approach: render your fill as a normal ::before element with a standard translateY animation. Then apply an SVG filter on the button that displaces pixels by a fractal noise field. The displacement only affects the edge visually — the underlying DOM structure stays clean. You define the filter once in a hidden SVG in the document and reference it via filter: url(#liquid-distort).

<!-- Drop this anywhere in your HTML, visibility:hidden -->
<svg style="position:absolute;width:0;height:0">
  <defs>
    <filter id="liquid-distort">
      <feTurbulence
        type="turbulence"
        baseFrequency="0.012 0.04"
        numOctaves="3"
        seed="2"
        result="noise"
      />
      <feDisplacementMap
        in="SourceGraphic"
        in2="noise"
        scale="8"
        xChannelSelector="R"
        yChannelSelector="G"
      />
    </filter>
  </defs>
</svg>
.liquid-btn-svg {
  position: relative;
  overflow: hidden;
  filter: url(#liquid-distort);
  /* everything below is the same fill animation */
}

.liquid-btn-svg::before {
  content: '';
  position: absolute;
  inset: 0;
  background: #ff3e6c;
  transform: translateY(100%);
  transition: transform 0.5s cubic-bezier(0.33, 1, 0.68, 1);
  z-index: -1;
}

.liquid-btn-svg:hover::before {
  transform: translateY(0%);
}

The feTurbulence with baseFrequency="0.012 0.04" (different x/y values) creates a horizontally stretched noise field, so the distortion reads as a horizontal wave rather than random noise blobs. Bump scale on feDisplacementMap above 15 and it starts looking more like gel than water — 6-10px is the sweet spot for buttons. Quick aside: feDisplacementMap runs on the GPU in every Chromium-based browser since version 91, so the performance hit is basically nil on modern hardware.

Animating the Turbulence Itself

Static turbulence looks good on hover but animating feTurbulence's baseFrequency through a <animate> element makes the liquid surface ripple continuously. This is the detail that separates "cool effect" from "I need to know how this was built."

<filter id="liquid-distort-animated">
  <feTurbulence
    type="turbulence"
    baseFrequency="0.012 0.04"
    numOctaves="3"
    seed="2"
    result="noise"
  >
    <animate
      attributeName="baseFrequency"
      dur="8s"
      values="0.010 0.035; 0.015 0.045; 0.010 0.035"
      repeatCount="indefinite"
    />
  </feTurbulence>
  <feDisplacementMap
    in="SourceGraphic"
    in2="noise"
    scale="8"
    xChannelSelector="R"
    yChannelSelector="G"
  />
</filter>

The values attribute interpolates between three states — the third matches the first, so the animation loops seamlessly without a snap. Keep the duration above 6s; anything faster reads as flickering rather than fluid motion. Look, this is the kind of detail nobody puts in tutorials but it makes a real difference: an 8-second cycle matches the pace at which real water surface tension oscillates at small scales, which is why it feels right.

One performance gotcha: SVG <animate> runs on the main thread in Firefox. In Chromium it's compositor-threaded. If your user base skews toward Firefox power users (developer tools, etc.) you might want to detect and offer a reduced version. The simplest way is checking navigator.userAgent — not ideal, but for a visual-only enhancement it's pragmatic.

If you want to go further, you can drive the scale attribute via JavaScript to pulse the distortion on hover — ramp it from 0 to 10 over 300ms using requestAnimationFrame with an easeOutExpo curve. That gives you the satisfying "pop" of the liquid splashing against the button boundary on entry.

Combining Both Techniques: The Full Component

Here's a React component that combines the clip-path wave leading edge with the SVG turbulence distortion. The fill layer uses translateY for the bulk travel and clip-path for the wave crest, while filter: url(#liquid-distort-animated) handles the organic wobble across the whole surface.

// LiquidButton.tsx
import { useId } from 'react';

interface LiquidButtonProps {
  children: React.ReactNode;
  fillColor?: string;
  textColor?: string;
  borderColor?: string;
  className?: string;
  onClick?: () => void;
}

export function LiquidButton({
  children,
  fillColor = '#6c47ff',
  textColor = '#6c47ff',
  borderColor = '#6c47ff',
  className = '',
  onClick,
}: LiquidButtonProps) {
  const filterId = useId().replace(/:/g, '');

  return (
    <>
      {/* Hidden SVG filter — one per button so filterId is unique */}
      <svg style={{ position: 'absolute', width: 0, height: 0 }}>
        <defs>
          <filter id={`liquid-${filterId}`}>
            <feTurbulence
              type="turbulence"
              baseFrequency="0.012 0.04"
              numOctaves="3"
              seed="2"
              result="noise"
            >
              <animate
                attributeName="baseFrequency"
                dur="8s"
                values="0.010 0.035; 0.015 0.045; 0.010 0.035"
                repeatCount="indefinite"
              />
            </feTurbulence>
            <feDisplacementMap
              in="SourceGraphic"
              in2="noise"
              scale="8"
              xChannelSelector="R"
              yChannelSelector="G"
            />
          </filter>
        </defs>
      </svg>

      <button
        onClick={onClick}
        className={`liquid-btn ${className}`}
        style={{
          ['--fill-color' as string]: fillColor,
          ['--text-color' as string]: textColor,
          ['--border-color' as string]: borderColor,
          ['--filter-id' as string]: `url(#liquid-${filterId})`,
        } as React.CSSProperties}
      >
        {children}
      </button>
    </>
  );
}
/* liquid-button.css */
.liquid-btn {
  position: relative;
  padding: 14px 36px;
  border: 2px solid var(--border-color);
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 600;
  color: var(--text-color);
  background: transparent;
  filter: var(--filter-id);
  isolation: isolate;
  transition: color 0.4s ease;
}

.liquid-btn::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--fill-color);
  transform: translateY(105%);
  transition: transform 0.5s cubic-bezier(0.33, 1, 0.68, 1);
  z-index: -1;
}

.liquid-btn:hover::before {
  transform: translateY(0%);
}

.liquid-btn:hover {
  color: #fff;
}

@media (prefers-reduced-motion: reduce) {
  .liquid-btn::before {
    transition: none;
  }
  .liquid-btn {
    filter: none;
  }
}

The useId() hook (React 18+) generates a stable unique ID per component instance, which is critical because multiple buttons on the same page each need their own filter element. Without unique IDs they'd all share one filter reference and you'd get filter-bleeding artifacts when hovering near page edges.

In practice, this component drops straight into any React or Next.js project with zero dependencies beyond what you already have. You can grab pre-styled variants from Empire UI that ship with matching dark mode tokens and animation presets — no need to tune the cubic-bezier values yourself.

Performance, Accessibility, and Edge Cases

SVG filters applied via CSS filter property do not trigger layout or paint — they're compositor-only, same as transform and opacity. That's the good news. The bad news is overflow: hidden combined with filter can sometimes cause clipping artifacts in Chromium at certain border-radius values above 12px. You'll notice a faint pixel bleed at the corners. Fix: add backface-visibility: hidden; transform: translateZ(0) to the button to force GPU layer promotion before the filter is applied.

The @media (prefers-reduced-motion: reduce) block in the CSS above is not optional. Users who have set this system preference often have vestibular disorders — animated fills that rush upward can trigger discomfort. With the reduced-motion override in place you just get a clean instant fill on hover with no SVG distortion, which is still a perfectly valid interaction.

One more thing — Safari handles filter: url(#id) referencing a DOM-defined SVG filter, but only if the SVG is in the same document. Cross-origin or <img> tag-embedded SVGs don't expose their filter graphs. Since the component inlines the SVG directly in the JSX this isn't an issue, but worth knowing if you try to move the SVG to an external file.

For glassmorphism-themed buttons you might want to combine this with a semi-transparent fill color and a backdrop-filter: blur(8px) on the fill layer. That's technically possible but backdrop-filter and SVG filter on the same element have quirky compositing behavior in Firefox 125 and earlier — test cross-browser before shipping. The glassmorphism generator can help you dial in the backdrop values visually before writing CSS.

Extending the Effect: Ripple, Jelly, and Color Shift

Once you have the base liquid fill working, three extensions are worth implementing. First: a radial ripple that originates from the click point. Track the mouse position on mousedown, position an absolutely-placed circle at those coordinates, and animate its scale from 0 to 2 with opacity fading out. Combine it with the liquid fill and you get an effect that reads as the click causing the liquid surge.

Second: a jelly wobble after the fill completes. Add a second keyframe set that runs after the fill animation finishes — slightly overshoots the clip-path or scale, then settles. You'd use animation-delay to trigger this after the 500ms fill transition. The cubic-bezier cubic-bezier(0.34, 1.56, 0.64, 1) gives you a spring-like overshoot without any JS physics library.

Third: mid-fill color shift. If your brand palette is complex, animate the fill color from, say, a warm amber to a deep violet across the same 500ms window using a background keyframe animation. This creates a gradient-in-motion effect that pairs beautifully with the gradient generator — grab a two-stop gradient from there and plug the hex values into the keyframes.

/* Jelly settle after fill — add to ::before */
.liquid-btn:hover::before {
  transform: translateY(0%);
  animation: liquid-settle 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.45s both;
}

@keyframes liquid-settle {
  0%   { transform: translateY(-6%) scaleX(1.02); }
  60%  { transform: translateY(2%)  scaleX(0.99); }
  100% { transform: translateY(0%)  scaleX(1); }
}

For design systems and component libraries, you'd wrap all these variants behind a variant prop — 'fill' | 'ripple' | 'jelly' — and conditionally apply the right CSS class or inline style. That's exactly how the animated button variants in Empire UI work under the hood. You get the animation complexity without maintaining it yourself.

FAQ

Can I animate clip-path with path() values across browsers?

Yes, as of 2026 all major browsers support interpolating path() values inside clip-path as long as the path strings have the same number and type of commands. Chrome 88+ and Firefox 112+ both handle it. Safari lagged behind but added support in Safari 17. Always match your M, C, L, and Z command count between keyframes or the animation will snap instead of interpolate.

Does the SVG feTurbulence filter hurt performance?

Not meaningfully on modern hardware. Chromium runs feDisplacementMap on the GPU compositor thread, so it doesn't block the main thread. You'll see a difference on very old mobile CPUs (pre-2019 mid-range Android), so include a prefers-reduced-motion fallback that removes the filter entirely. For desktop and recent mobile it's essentially free.

Why does my liquid button clip at the corners when border-radius is set?

This is a known Chromium compositing quirk when combining overflow: hidden, border-radius, and filter: url(#...) on the same element. Add transform: translateZ(0) and backface-visibility: hidden to the button to force GPU layer promotion before the filter stack kicks in. That resolves the clipping artifact in Chrome and Edge.

How do I make the liquid fill accessible?

Wrap all transition and animation rules in @media (prefers-reduced-motion: no-preference) or add a @media (prefers-reduced-motion: reduce) block that sets transition: none and removes the SVG filter. The button still works — it just switches state instantly. This is the minimum required to avoid triggering discomfort in users with vestibular disorders.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Shape Morphing: clip-path, offset-path and SVG Morph TechniquesCSS Motion Path: Animate Elements Along a Custom SVG PathClaymorphism Button: 3D Clay Press Animation in CSSNeon Button in CSS: Glowing Border, Pulsing Light, Hover Bloom