There is no GSAP plugin for liquid hover effects, and you do not need one. Every liquid hover you have seen is built from one of three things: an SVG displacement filter, a gooey SVG filter, or WebGL. They look different: the displacement filter melts and wobbles, the gooey filter merges shapes, and only WebGL makes rings of water that spread and interfere. GSAP’s part is small in all three: it tweens a value or runs the frame loop.

This post builds a minimal, working version of each, with reduced-motion handling, then compares them on cost, browser behaviour and what each is good for.

Why there is no liquid plugin

GSAP animates values: CSS properties, attributes, and properties on plain objects. A liquid look is a rendering problem, not a timing problem, so it lives in whatever draws the pixels.

The official plugin closest to “liquid” is MorphSVGPlugin, which morphs one SVG path into another. That suits blobby shapes, but it cannot distort a photo or make two elements melt together. For that you need a filter or a shader, and GSAP drives it through the attribute support built into its core:

gsap.to('#liquid feDisplacementMap', { attr: { scale: 60 } });

That one line is the whole integration for the first technique.

Technique 1: SVG displacement filter (feTurbulence + feDisplacementMap)

feTurbulence generates a field of Perlin-style noise. feDisplacementMap then shifts every pixel of the source by an amount read from that noise. The scale attribute sets how far pixels move, so 0 is a sharp image and 60 is a heavy wobble. Tween scale and the image melts and reforms.

Be clear about what this looks like: the whole image warps through a noise field, like heat haze or a rough melt. It does not make rings or drips, its edges turn ragged at high scale, and nothing spreads out from the pointer. That is a fine look for a subtle hover, but for water, or for a melt that looks like real liquid, you need WebGL (see Technique 3 and Choosing a technique).

The markup

Put the filter in a zero-size SVG anywhere in the page, and give the image a wrapper you can hover and focus:

<svg width="0" height="0" style="position: absolute" aria-hidden="true" focusable="false">
  <filter id="liquid" x="-10%" y="-10%" width="120%" height="120%">
    <feTurbulence type="fractalNoise" baseFrequency="0.01" numOctaves="2" seed="4" result="noise" />
    <feDisplacementMap in="SourceGraphic" in2="noise" scale="0" xChannelSelector="R" yChannelSelector="G" />
  </filter>
</svg>

<a class="liquid-card" href="/work/harbour">
  <img src="harbour.jpg" alt="Boats moored in a harbour at dusk" width="1200" height="800">
</a>

The CSS

The filter is attached only while the class is present. A filter with scale="0" draws nothing visible, but the browser still runs it on every repaint, so leaving it on at rest costs you for nothing.

.liquid-card {
  display: block;
  overflow: hidden; /* trims the wobbly edges */
  border-radius: 12px;
}

.liquid-card img {
  display: block;
  width: 100%;
  height: auto;
}

.liquid-card.is-liquid img {
  filter: url(#liquid);
}

The JavaScript

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const card = document.querySelector('.liquid-card');
const noise = document.querySelector('#liquid feTurbulence');
const displace = document.querySelector('#liquid feDisplacementMap');

function melt() {
  if (reduceMotion.matches) return;
  card.classList.add('is-liquid');
  gsap.to(displace, { attr: { scale: 60 }, duration: 0.5, ease: 'power2.out', overwrite: true });
  gsap.to(noise, { attr: { baseFrequency: 0.02 }, duration: 0.5, ease: 'power2.out', overwrite: true });
}

function settle() {
  gsap.to(noise, { attr: { baseFrequency: 0.01 }, duration: 1.2, ease: 'power3.out', overwrite: true });
  gsap.to(displace, {
    attr: { scale: 0 },
    duration: 1.2,
    ease: 'power3.out',
    overwrite: true,
    onComplete: () => card.classList.remove('is-liquid'),
  });
}

card.addEventListener('pointerenter', melt);
card.addEventListener('pointerleave', settle);
card.addEventListener('focus', melt);
card.addEventListener('blur', settle);

Three details do most of the work:

  • Asymmetric timing. Distorting fast and reforming slowly is what reads as viscous rather than glitchy.
  • Tweening baseFrequency too. Raising it makes the ripples tighter as the effect peaks. Changing it means the noise is regenerated each frame, so if a large image stutters, animate scale alone.
  • Reduced motion. The check sits inside melt(), so it follows the visitor’s setting even if they change it mid-session. The image simply stays sharp.

Technique 2: gooey filter (blur + alpha threshold)

The gooey effect makes separate shapes merge like drops of liquid when they get close. It is two filter primitives: blur everything, then use feColorMatrix to multiply the alpha channel and subtract an offset. Soft blurred edges become hard again, and where two blurs overlapped, the overlap survives as a bridge between the shapes.

Gooey Liquid Nav, which takes the same idea further with lit, glossy liquid. Click and hover the nav items inside the frame. Get the full code →

The markup

The blobs sit in their own filtered layer behind the links. Text inside a gooey filter would be blurred and thresholded into mush, so the labels stay outside it.

<svg width="0" height="0" style="position: absolute" aria-hidden="true" focusable="false">
  <filter id="goo" x="-20%" y="-50%" width="140%" height="200%">
    <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 20 -9" />
  </filter>
</svg>

<nav class="goo-nav" aria-label="Main">
  <div class="goo-layer" aria-hidden="true">
    <span class="blob"></span>
    <span class="blob"></span>
    <span class="blob"></span>
  </div>
  <a class="goo-link" href="/" aria-current="page">Work</a>
  <a class="goo-link" href="/studio">Studio</a>
  <a class="goo-link" href="/journal">Journal</a>
  <a class="goo-link" href="/contact">Contact</a>
</nav>

The last row of the matrix is the alpha line: new alpha is old alpha times 20, minus 9. Raise the multiplier for crisper edges; change the offset to make the bridges thicker or thinner. The enlarged filter region stops the blur being clipped at the edges of the layer.

The CSS

.goo-nav {
  position: relative;
  display: inline-flex;
  gap: 8px;
  padding: 8px;
}

.goo-layer {
  position: absolute;
  inset: 0;
  filter: url(#goo);
  pointer-events: none;
}

.blob {
  position: absolute;
  top: 8px;
  left: 0;
  height: calc(100% - 16px);
  border-radius: 999px;
  background: #ffc93c;
}

.goo-link {
  position: relative;
  padding: 12px 20px;
  color: #111;
  text-decoration: none;
}

The JavaScript

Three blobs travel to the hovered link with a small stagger. While they are spread out, the filter joins them into one stretched body; on a long jump the lead breaks away and the others merge back into it when they arrive.

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const nav = document.querySelector('.goo-nav');
const links = [...nav.querySelectorAll('.goo-link')];
const blobs = nav.querySelectorAll('.blob');
const current = nav.querySelector('[aria-current="page"]') || links[0];

function moveTo(link, instant = false) {
  const target = { x: link.offsetLeft, width: link.offsetWidth };
  if (instant || reduceMotion.matches) {
    gsap.set(blobs, target);
    return;
  }
  gsap.to(blobs, {
    ...target,
    duration: 0.6,
    ease: 'power3.inOut',
    stagger: 0.06,
    overwrite: true,
  });
}

links.forEach((link) => {
  link.addEventListener('pointerenter', () => moveTo(link));
  link.addEventListener('focus', () => moveTo(link));
});

nav.addEventListener('pointerleave', () => moveTo(current));
nav.addEventListener('focusout', (event) => {
  if (!nav.contains(event.relatedTarget)) moveTo(current);
});

moveTo(current, true);

Under reduced motion the indicator still follows hover and focus, so the nav keeps its meaning, but it jumps rather than flows. Two limits to know: the threshold only works on solid fills (gradients and photos come out posterised), and the blob colour must contrast with the label text wherever the two overlap.

Technique 3: WebGL water ripple

To look like water, a ripple needs memory. A wave started by the pointer has to keep travelling after the pointer has moved on, spread into a ring, pass through other rings and slowly die away. A single shader that draws sin(distance) around the pointer cannot do that: it draws the same ring wherever the pointer is, and the ring vanishes the moment the pointer leaves.

The standard answer is a height field. A small texture stores the water’s height at each point; every step, each cell is pulled toward the average of its neighbours, which is the wave equation in its simplest form. Disturb one spot and the dip travels outward as a ring on its own. A second shader then draws the image through that surface: it shifts the image along the slope of the water (refraction) and brightens the crests that face the light.

The full Liquid Ripple Image effect, which this tutorial builds a simpler version of. Drag across the water inside the frame, or click to drop a stone. Get the full code →

The version below uses raw WebGL 2, so there is nothing to install beyond GSAP. The image stays in the page as a real <img>, and the canvas is laid over it only when WebGL is available and motion is allowed, so every fallback is the plain picture.

The markup and CSS

<div class="ripple">
  <img src="pool.jpg" alt="Turquoise pool tiles under still water" width="1920" height="1080">
</div>
.ripple { position: relative; touch-action: pan-y; }
.ripple img { display: block; width: 100%; height: auto; }
.ripple canvas { position: absolute; inset: 0; width: 100%; height: 100%; }

touch-action: pan-y keeps vertical page scrolling working when a finger lands on the image; a sideways drag ripples it.

The JavaScript

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const wrap = document.querySelector('.ripple');
const img = wrap.querySelector('img');
const CELL = 4; // CSS pixels per simulation cell

const vertex = `#version 300 es
  in vec2 aPos;
  out vec2 vUv;
  void main() {
    vUv = aPos * 0.5 + 0.5;
    gl_Position = vec4(aPos, 0.0, 1.0);
  }`;

// One step of the water: r is the height now, g the height one step ago.
const simulate = `#version 300 es
  precision highp float;
  uniform sampler2D uState;
  uniform vec2 uTexel;
  uniform vec3 uDrop; // x, y in cells, z = amount
  in vec2 vUv;
  out vec4 outColor;
  void main() {
    vec2 now = texture(uState, vUv).rg;
    float sum = texture(uState, vUv + vec2(uTexel.x, 0.0)).r
              + texture(uState, vUv - vec2(uTexel.x, 0.0)).r
              + texture(uState, vUv + vec2(0.0, uTexel.y)).r
              + texture(uState, vUv - vec2(0.0, uTexel.y)).r;
    float next = (sum * 0.5 - now.g) * 0.985;
    float dist = distance(vUv / uTexel, uDrop.xy);
    next -= uDrop.z * smoothstep(5.0, 0.0, dist);
    outColor = vec4(next, now.r, 0.0, 1.0);
  }`;

// The image seen through the water: shifted along the slope, lit on the crests.
const display = `#version 300 es
  precision highp float;
  uniform sampler2D uImage;
  uniform sampler2D uState;
  uniform vec2 uTexel;
  in vec2 vUv;
  out vec4 outColor;
  float height(vec2 offset) { return texture(uState, vUv + offset * uTexel).r; }
  void main() {
    vec2 slope = vec2(height(vec2(1.0, 0.0)) - height(vec2(-1.0, 0.0)),
                      height(vec2(0.0, 1.0)) - height(vec2(0.0, -1.0)));
    vec3 color = texture(uImage, vUv + slope * 0.5).rgb;
    float light = dot(slope, vec2(-0.6, 0.8));
    color += clamp(light * 6.0, -0.15, 0.4);
    outColor = vec4(color, 1.0);
  }`;

function initRipple() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl2');
  // Rendering into float textures needs this extension; without it, keep the <img>.
  if (!gl || !gl.getExtension('EXT_color_buffer_float')) return;

  const program = (fragmentSource) => {
    const p = gl.createProgram();
    [[gl.VERTEX_SHADER, vertex], [gl.FRAGMENT_SHADER, fragmentSource]].forEach(([type, source]) => {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      gl.attachShader(p, shader);
    });
    gl.bindAttribLocation(p, 0, 'aPos');
    gl.linkProgram(p);
    return p;
  };
  const simProgram = program(simulate);
  const displayProgram = program(display);
  if (!gl.getProgramParameter(simProgram, gl.LINK_STATUS) || !gl.getProgramParameter(displayProgram, gl.LINK_STATUS)) return;

  // One quad covering the canvas, shared by both programs
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

  const makeTexture = () => {
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    return texture;
  };

  // The photograph
  const image = makeTexture();
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);

  // Two water textures that swap roles every step: read one, write the other
  let cols = 0;
  let rows = 0;
  const water = [0, 1].map(() => ({ texture: makeTexture(), buffer: gl.createFramebuffer() }));

  function resize() {
    const dpr = Math.min(window.devicePixelRatio, 2);
    canvas.width = wrap.clientWidth * dpr;
    canvas.height = wrap.clientHeight * dpr;
    cols = Math.ceil(wrap.clientWidth / CELL);
    rows = Math.ceil(wrap.clientHeight / CELL);
    water.forEach(({ texture, buffer }) => {
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, cols, rows, 0, gl.RGBA, gl.HALF_FLOAT, null);
      gl.bindFramebuffer(gl.FRAMEBUFFER, buffer);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT); // calm water
    });
  }

  const drops = [];
  let lastTouch = -Infinity;
  let pending = 0;

  function step(drop) {
    const [read, write] = water;
    gl.useProgram(simProgram);
    gl.bindFramebuffer(gl.FRAMEBUFFER, write.buffer);
    gl.viewport(0, 0, cols, rows);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, read.texture);
    gl.uniform1i(gl.getUniformLocation(simProgram, 'uState'), 0);
    gl.uniform2f(gl.getUniformLocation(simProgram, 'uTexel'), 1 / cols, 1 / rows);
    gl.uniform3f(gl.getUniformLocation(simProgram, 'uDrop'), drop[0], drop[1], drop[2]);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    water.reverse();
  }

  function draw() {
    gl.useProgram(displayProgram);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, image);
    gl.activeTexture(gl.TEXTURE1);
    gl.bindTexture(gl.TEXTURE_2D, water[0].texture);
    gl.uniform1i(gl.getUniformLocation(displayProgram, 'uImage'), 0);
    gl.uniform1i(gl.getUniformLocation(displayProgram, 'uState'), 1);
    gl.uniform2f(gl.getUniformLocation(displayProgram, 'uTexel'), 1 / cols, 1 / rows);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  }

  // Fixed 120 steps a second, so waves travel at the same speed on any display
  function tick(time, deltaTime) {
    pending += Math.min(deltaTime, 50) / 1000;
    while (pending >= 1 / 120) {
      step(drops.shift() || [0, 0, 0]);
      pending -= 1 / 120;
    }
    draw();
    if (time - lastTouch > 4) stop(); // the rings have faded: rest
  }

  function start() {
    lastTouch = gsap.ticker.time;
    gsap.ticker.remove(tick);
    gsap.ticker.add(tick);
  }

  function stop() {
    gsap.ticker.remove(tick);
    resize(); // clear the water to exactly flat
    draw();
  }

  function dropAt(event, amount) {
    const rect = wrap.getBoundingClientRect();
    const x = (event.clientX - rect.left) / CELL;
    const y = (rect.bottom - event.clientY) / CELL;
    drops.push([x, y, amount]);
    start();
  }

  wrap.addEventListener('pointermove', (event) => dropAt(event, 0.04));
  wrap.addEventListener('pointerdown', (event) => dropAt(event, 0.6));

  wrap.append(canvas);
  resize();
  draw();
  new ResizeObserver(() => { resize(); draw(); }).observe(wrap);
}

if (!reduceMotion.matches) {
  if (img.complete) initRipple();
  else img.addEventListener('load', initRipple, { once: true });
}

How the pieces fit:

  • Two textures, swapped every step. A shader cannot read and write the same texture, so one holds the current water and the other receives the next step. The red channel is the height now and the green channel the height one step ago, which is all the wave equation needs.
  • Fixed steps, not one per frame. The loop runs 120 simulation steps a second whatever the display’s refresh rate, so rings travel at the same speed on a 60Hz laptop and a 120Hz phone. gsap.ticker supplies the frames and the elapsed time.
  • Damping settles it. Multiplying every step by 0.985 drains the energy, so the rings fade. Four seconds after the last touch the loop stops and the water is cleared to exactly flat, so a calm image costs nothing per frame.
  • Push down, not up. The pointer subtracts height, like a finger pressing the surface. A move adds a small dent, a click a deep one, and the difference is what makes a click read as a dropped stone.

Tuning: raise 0.5 in the display shader for deeper-looking water, lower CELL for finer ripples (at a higher GPU cost), and move the damping toward 0.995 for rings that travel further. Three gotchas:

  • The image must be same-origin or served with CORS headers (and crossorigin="anonymous" on the <img>). A cross-origin image without them cannot be uploaded as a texture.
  • Rendering into half-float textures needs EXT_color_buffer_float. Current desktop and mobile browsers have it; the code checks and keeps the plain image where it is missing.
  • Browsers cap live WebGL contexts per page. One canvas per image is fine for a hero or a few cards; a grid of thirty needs one shared canvas, which is where a library such as three.js, OGL or PixiJS earns its weight.

Choosing a technique

SVG displacementGooey filterWebGL water ripple
Best forWobble or heat haze on images and typeBlobs that merge: indicators, buttons, cursorsWater: rings that spread, cross and settle
Extra codeA few linesA few linesTwo shaders and a small loop, or a library
Where the work runsBrowser SVG filter pipelineBrowser SVG filter pipelineGPU: a simulation plus a display shader
Weak spotLarge images, especially in SafariGradients, photos, text inside the layerContext limits, CORS, more code to own
FallbackSharp imageIndicator jumps instead of flowingPlain <img>

Use the SVG displacement filter when you want a wobble or a rough melt on a few images or headlines and would rather not ship a canvas. It will not look like rippling water, and it is not meant to. It is supported in every current browser. Attach it only during the hover and keep filtered elements modest in size: Safari is where a big filtered image is most likely to drop frames, so test there.

Use the gooey filter when the thing that moves is a solid shape. It is cheap at nav and button sizes, and the effect falls apart on anything that is not a flat fill.

Use WebGL when you want water: rings that spread from the pointer, interfere and settle back to a calm image. It also holds up on large images where the SVG route stutters. You trade a few lines of markup for a renderer you have to own.

WebGL is also the route to a melt that reads as real liquid. A displacement filter can only push pixels through noise; a shader can pull an image downward into drips, keep its edges rounded and light it as a glossy surface. Liquid Morph is that kind of melt: photographs sag, smear and drip, then spring back crisp, and every image on a page shares one canvas, which sidesteps the context limit.

Want more control?

The snippets above are the technique at its simplest. The finished effects add the parts that take the longest to tune:

  • Liquid Ripple Image: a speed-sensitive wake (a slow drag ripples softly, a fast flick cuts deep), a tap or keyboard button that drops a stone into a train of rings, a lit surface with a glint on the crests, rings that leave the frame instead of bouncing, any image at any aspect ratio, optional idle stones, and a loop that sleeps once the water is calm.
  • Liquid Morph: a WebGL melt in which photographs sag into glossy liquid and rounded drips, then spring back crisp; hover, tap, focus, scroll-into-view, scroll-scrubbed and continuous triggers; per-image intensity, drip length and gloss; a melt-everything control with aria-pressed state; and one shared canvas for any number of images.
  • Gooey Liquid Nav: a lit, glossy liquid indicator that stretches, splits and drips between items, labels that change colour exactly where the liquid covers them, and full arrow-key navigation.

For liquid on scroll rather than hover, Liquid Fill Reveal fills a frame with water whose surface sloshes when you scroll fast.

That’s it

No plugin, three techniques: a displacement filter for warping images, a gooey filter for merging shapes, and a small water simulation for ripples that actually look like water. GSAP’s job stays small in each: tween a filter value, or run the loop and stop it when the surface is calm.


Browse every GSAP liquid effect with live demos, or the wider set of GSAP hover effects for other ways to respond to the pointer.