EmpireUI
Get Pro
← Blog7 min read#css-animation#svg-filter#button-animation

Liquid Button Animation: Blob Hover with SVG filter

Build a liquid blob button using SVG feTurbulence and feDisplacementMap filters in React. Step-by-step with Tailwind v4 and real code you can ship today.

Colorful liquid blob shapes morphing against a dark background, illustrating SVG filter animation effects

Why Liquid Button Animations Actually Work

Honestly, most button hover effects are boring. A color shift, maybe a slight scale transform — developers have been copy-pasting that pattern since 2015 and users barely notice it anymore. A liquid blob animation is different. It triggers a visceral reaction because it behaves like something physical, something alive.

The technique relies on SVG filter primitives — specifically feTurbulence and feDisplacementMap — applied to a button's background element. When a user hovers, you animate the baseFrequency attribute on the turbulence filter. The result is a background that appears to ripple and morph like water or slime. It's weird in the best way.

This isn't a gimmick for portfolio sites only. SaaS dashboards, landing pages, and e-commerce CTAs have all used this pattern to bump click-through rates. The motion draws the eye without being distracting in static state. That's the balance you're after.

How SVG Filters Produce the Blob Shape

SVG filters work by processing pixel data through a pipeline of primitive operations. You define them in a <filter> element and reference them via filter: url(#myFilter) in CSS. The browser then rasterizes the target element, runs it through the pipeline, and composites the result back. It's GPU-accelerated in Chrome, Firefox, and Safari — so performance is solid.

feTurbulence generates a noise texture based on Perlin or fractal noise algorithms. The baseFrequency attribute (something like 0.015 0.012) controls the scale of the noise. Low values produce large, slow blobs. Higher values give you fine, chaotic ripples. The numOctaves attribute — keep it at 2 for performance — layers detail on top of that base noise.

feDisplacementMap takes that noise texture and uses it to spatially offset the pixels of your source graphic. The scale attribute is your intensity knob. At scale="0" nothing happens. At scale="20" the edges start to look liquid. At scale="60" you get full-on blob territory. The trick is animating scale and baseFrequency together on hover for a smooth morph transition.

Setting Up the SVG Filter in React

You'll define the filter once in your component and reference it with a unique ID. Using a unique ID matters if you're rendering multiple liquid buttons on the same page — shared IDs will cause filters to bleed across elements in some browsers.

Here's a minimal working setup. This renders a hidden SVG with the filter definition, then applies it to a div that acts as the button background layer.

import { useRef, useState } from 'react';

const FILTER_ID = 'liquid-blob-filter';

export function LiquidButton({ children }: { children: React.ReactNode }) {
  const [hovered, setHovered] = useState(false);

  return (
    <div className="relative inline-flex items-center justify-center">
      {/* Hidden SVG — defines the filter only */}
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="0"
        height="0"
        style={{ position: 'absolute' }}
      >
        <defs>
          <filter id={FILTER_ID} x="-30%" y="-30%" width="160%" height="160%">
            <feTurbulence
              type="fractalNoise"
              baseFrequency={hovered ? '0.012 0.010' : '0.030 0.025'}
              numOctaves={2}
              seed={8}
              result="noise"
              style={{ transition: 'all 0.6s ease' }}
            />
            <feDisplacementMap
              in="SourceGraphic"
              in2="noise"
              scale={hovered ? 55 : 0}
              xChannelSelector="R"
              yChannelSelector="G"
            />
          </filter>
        </defs>
      </svg>

      {/* Button shell */}
      <button
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        className="relative z-10 px-8 py-3 text-white font-semibold text-sm tracking-wide"
        style={{ background: 'transparent' }}
      >
        {/* Animated background blob */}
        <span
          className="absolute inset-0 rounded-full"
          style={{
            background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
            filter: `url(#${FILTER_ID})`,
            transition: 'filter 0.6s ease',
          }}
        />
        <span className="relative">{children}</span>
      </button>
    </div>
  );
}

Notice that the feTurbulence SVG attributes can't be CSS-transitioned directly — SVG attribute animation uses SMIL or JavaScript. For a pure React approach, you toggle state on hover and let React re-render the attribute values. The visual transition comes from animating the filter CSS property on the span, not the SVG attributes themselves. You'll add a CSS transition on the wrapper to smooth the displacement scale change.

Animating the Blob with CSS Transitions and Keyframes

The SVG attribute swap on hover gives you the static before/after states. To get a continuous, breathing animation in the idle state, you need CSS keyframes on a separate element — or a requestAnimationFrame loop that updates the baseFrequency value dynamically. The keyframe approach is simpler and cheaper on the main thread.

/* globals.css — works with Tailwind v4.0.2 */
@keyframes blob-idle {
  0%, 100% {
    border-radius: 60% 40% 55% 45% / 50% 60% 40% 55%;
  }
  33% {
    border-radius: 45% 55% 40% 60% / 60% 40% 55% 45%;
  }
  66% {
    border-radius: 55% 45% 60% 40% / 45% 55% 50% 50%;
  }
}

.liquid-blob-idle {
  animation: blob-idle 5s ease-in-out infinite;
}

.liquid-blob-idle:hover {
  animation-play-state: paused;
}

This CSS-only blob uses border-radius morphing on the background element instead of SVG filters. It's a lighter approach and still looks great. You can combine both: use border-radius animation for the idle breathing, then apply the SVG filter displacement on hover for the full liquid effect. That layered approach gives you the best of both worlds without hammering the GPU constantly.

Tailwind v4 Utilities for the Button Layout

With Tailwind v4.0.2, you don't need a custom config to get the utility classes this button needs. The relative, absolute, inset-0, rounded-full, and z-10 classes all ship out of the box. What you will need to handle manually is the filter: url(#id) reference — Tailwind doesn't generate arbitrary filter references, so that stays inline.

One thing worth knowing: Tailwind's transition-all class adds transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms. That duration is too fast for a liquid effect — it'll look like a glitch. Use duration-500 or duration-700 instead, or set the transition inline as transition: all 0.6s ease. The 600ms sweet spot lets the displacement scale animate smoothly without feeling sluggish.

If you're wiring this up inside a component that also handles theming, check out how to build a theme toggle in React — the same data-theme attribute approach works well here for swapping the gradient colors in dark mode without duplicating component logic.

Performance Considerations and Browser Gotchas

SVG filters are expensive when applied to large surfaces. Keep the filter region tight — the x, y, width, height attributes on <filter> control how far outside the element the filter can render. x="-30%" y="-30%" width="160%" height="160%" gives you enough bleed for the liquid morphing without processing an unnecessarily large area. Going to 200% or beyond will noticeably tank performance on low-end devices.

Safari handles feTurbulence differently from Chrome. The seed attribute affects the noise pattern, and values that look great in Chrome can produce asymmetric blobs in Safari. Test with seed values between 1 and 20. Also, Safari prior to version 17 has a bug where feDisplacementMap with scale values above 50 clips the output to the original bounding box — keep scale at 45 or below if you need broad Safari support.

Firefox is actually the best performer here. It rasterizes SVG filters off the main thread more aggressively than Chrome does. If you're seeing jank in Chrome DevTools, profile in Firefox to confirm whether it's a rendering pipeline issue or a JavaScript one. Speaking of backgrounds that push the GPU — particles backgrounds in React have similar optimization trade-offs worth reading about.

Combining Liquid Buttons with Background Animations

A liquid button on a flat background looks good. A liquid button over a moving aurora background looks cinematic. The key to combining animated elements is layering them with explicit z-index values and ensuring the background animation doesn't trigger layout recalculations that affect the button's filter region.

Use will-change: filter on the blob background span. This hint tells the browser to promote that element to its own compositor layer, which means filter changes don't trigger repaints on the parent. It's not a magic fix, but it does reduce visual tearing during the hover transition.

Can you go too far with animation layering? Absolutely. If your button sits over a turbulence-filtered background AND has its own displacement filter, the GPU load compounds. One animated layer at a time is the practical limit for mobile-first experiences. Decide what gets the motion budget — the background or the interactive element — and let the other one be static or minimal.

Accessibility and Reduced Motion

This one's non-negotiable. The prefers-reduced-motion media query exists because vestibular disorders make certain animations genuinely painful. If a user has requested reduced motion, your liquid button should fall back to a simple opacity or color change — no displacement, no border-radius morphing.

import { useEffect, useState } from 'react';

function usePrefersReducedMotion() {
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return reduced;
}

// In your LiquidButton component:
const reducedMotion = usePrefersReducedMotion();
const filterStyle = reducedMotion
  ? undefined
  : `url(#${FILTER_ID})`;

When reducedMotion is true, drop the filter reference entirely and let the button render as a standard gradient rounded pill. The user gets a functional, visually distinct button — they just don't get the liquid morphing. That's the right call. Does your current component library handle this consistently? It's worth auditing every animated component with a single usePrefersReducedMotion hook extracted to a shared file.

FAQ

Why does my SVG filter cause the button text to blur or distort?

The filter CSS property applies to the entire element including its children. Apply the filter only to the background span (the absolute inset-0 element), not to the button itself. The text lives in a separate relative span that sits above the filtered layer, so it stays crisp.

Can I use this without React — just plain HTML and CSS?

Yes. Define the <svg> with the filter in your HTML, add filter: url(#your-filter-id) to the button's ::before pseudo-element, and trigger the hover state change with a CSS custom property updated via JavaScript's addEventListener('mouseenter', ...). No framework required.

The animation looks great in Chrome but jumpy in Safari 16. What's wrong?

Safari 16 clips feDisplacementMap output to the source bounding box when scale exceeds roughly 45-50. Reduce your scale value and increase the filter region (x="-40%" y="-40%" width="180%" height="180%") to compensate. Safari 17+ fixed this clipping behavior.

How do I make the blob color follow my Tailwind CSS theme variables?

Use CSS custom properties in the gradient: background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)). Define those variables in your :root and [data-theme='dark'] selectors. Tailwind v4.0.2 lets you define these in @layer base and they'll pick up dark mode automatically via the cascade.

Is `will-change: filter` safe to use on many buttons on the same page?

Not if you have dozens of them. Each will-change: filter promotes the element to a compositor layer, consuming GPU memory. Apply it conditionally — only when the user is hovering — by adding it in a mouseenter handler and removing it in mouseleave. That way layers are only promoted when they're actively animating.

Does this work inside a Next.js 15 app with the App Router?

Yes, but mark the component with 'use client' since it uses useState and useEffect. The SVG filter renders fine on the client. If you need SSR compatibility without hydration mismatches, initialize the hovered state to false (which it already is) and avoid reading window at module level — the usePrefersReducedMotion hook pattern shown above handles this correctly with useEffect.

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

Read next

Loading Spinner Designs: 10 Pure CSS Animations for Every BrandMorphing Shapes with CSS: clip-path Transitions and SVG AnimationTailwind Animation Library: 30 Classes for Common EffectsGlow Button in React: CSS Box Shadow Animation on Hover