---
name: liquid-image-distortion
description: Use when you want "Experimental, premium, exotic" - Warps images like water or liquid when hovered or moved.
---

# Liquid Image Distortion

> **Category:** Image / Media  -  **Personality:** Experimental, premium, exotic
>
> **Best use:** Creative agencies, art, fashion
>
> **Ratings:** Professional 3/5 - Casual 3/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 4/5

## What it is

An image that ripples and warps like the surface of water when you move the pointer over it. A hidden SVG noise texture (`feTurbulence`) is used as a displacement field (`feDisplacementMap`) that pushes the image's pixels around; a few lines of vanilla JS ease the displacement strength up from zero on hover and let the noise drift so the warp *flows* instead of sitting frozen. It is pure SVG — no WebGL, no library — which makes it robust and easy to drop onto any `<img>`. It reads as tactile and high-craft, so it suits fashion lookbooks, art/photography portfolios and creative-agency hero imagery where a "touch the surface" moment is worth the cost.

## Dependencies / CDN

**None — pure SVG filter + vanilla JS.** No WebGL, no three.js, no CDN.

The demo's display/body fonts are optional dressing:

```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=Instrument+Serif:ital@0;1&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

A `<figure>` carrying its own visually-hidden SVG filter; the image references the filter by id. Give every instance a **unique filter id**.

```html
<figure class="liquid" data-scale="34">
  <svg width="0" height="0" aria-hidden="true" focusable="false">
    <filter id="liquid-fx" x="-16%" y="-16%" width="132%" height="132%"
            color-interpolation-filters="sRGB">
      <feTurbulence class="turb" type="fractalNoise" baseFrequency="0.009 0.013"
                    numOctaves="2" seed="7" stitchTiles="stitch" result="noise"/>
      <feDisplacementMap class="disp" in="SourceGraphic" in2="noise" scale="0"
                         xChannelSelector="R" yChannelSelector="G"/>
    </filter>
  </svg>

  <div class="frame">
    <img src="/path/to/portrait.jpg" alt="…" draggable="false" style="filter:url(#liquid-fx)">
    <span class="glint" aria-hidden="true"></span>
  </div>
</figure>
```

## CSS

```css
.frame{position:relative; overflow:hidden; border-radius:18px; aspect-ratio:4/5}

/* Oversize the image so the displaced edge fringe stays clipped behind the frame */
.frame img{width:100%; height:100%; object-fit:cover; transform:scale(1.12); display:block}

/* a wet "specular" highlight that follows the pointer (pure dressing) */
.glint{position:absolute; inset:0; pointer-events:none; opacity:0; mix-blend-mode:screen;
  background:radial-gradient(180px 180px at var(--mx,50%) var(--my,50%),
    rgba(122,247,230,.42), rgba(155,140,255,.16) 42%, transparent 70%);
  transition:opacity .45s ease}
.liquid:hover .glint{opacity:1}

/* Reduced motion: no glint, and the JS below keeps the displacement at 0 */
@media (prefers-reduced-motion: reduce){ .glint{display:none} }
```

## JavaScript

```js
document.querySelectorAll('.liquid').forEach(function(fig){
  var disp = fig.querySelector('.disp');   // feDisplacementMap
  var turb = fig.querySelector('.turb');   // feTurbulence
  if(!disp || !turb) return;

  // Reduced motion: leave the image perfectly sharp and run nothing.
  if(matchMedia('(prefers-reduced-motion: reduce)').matches){ disp.setAttribute('scale','0'); return; }

  var max = parseFloat(fig.dataset.scale) || 32;
  var b  = (turb.getAttribute('baseFrequency') || '0.01').split(/\s+/);
  var bx = parseFloat(b[0]) || 0.01, by = parseFloat(b[1]) || bx;

  var scale=0, target=0, burst=0, phase=Math.random()*6.28, raf=0, lx=0, ly=0, tracked=false;

  function frame(){
    var want = target + burst;
    scale += (want - scale) * 0.14;   // spring toward the target distortion
    burst *= 0.90;                     // the pointer "splash" decays back to calm
    phase += 0.02;
    // nudge the noise frequency so the liquid keeps flowing while it's displaced
    turb.setAttribute('baseFrequency',
      (bx + Math.sin(phase)        * bx * 0.20).toFixed(4) + ' ' +
      (by + Math.cos(phase * 0.85) * by * 0.20).toFixed(4));
    disp.setAttribute('scale', scale.toFixed(2));
    if(scale > 0.06 || want > 0.06){ raf = requestAnimationFrame(frame); }
    else { disp.setAttribute('scale','0'); turb.setAttribute('baseFrequency', bx+' '+by); raf=0; }
  }
  function kick(){ if(!raf) raf = requestAnimationFrame(frame); }

  fig.addEventListener('pointerenter', function(){ target = max; kick(); });
  fig.addEventListener('pointerleave', function(){ target = 0; tracked = false; kick(); });
  fig.addEventListener('pointermove', function(e){
    var r = fig.getBoundingClientRect(), x = e.clientX-r.left, y = e.clientY-r.top;
    fig.style.setProperty('--mx', (x / r.width  * 100).toFixed(1) + '%');
    fig.style.setProperty('--my', (y / r.height * 100).toFixed(1) + '%');
    if(tracked){
      var d = Math.sqrt((x-lx)*(x-lx) + (y-ly)*(y-ly));
      burst = Math.min(burst + d * 0.28, 30);   // flicking across = a bigger ripple
    }
    lx = x; ly = y; tracked = true; kick();
  });
});
```

## How it works

- **`feTurbulence`** renders a fractal (Perlin) noise image. Its **red and green channels** become a per-pixel vector field — effectively "shift this pixel by *(R-0.5, G-0.5)*".
- **`feDisplacementMap`** reads that field and moves each pixel of `SourceGraphic` (the `<img>`): `x' = x + scale·(R-0.5)`, `y' = y + scale·(G-0.5)`. At `scale="0"` the image is untouched; as `scale` grows the picture warps like water.
- The JS only ever changes **two attributes**: it eases `scale` from 0 toward a target (a simple spring), and slowly oscillates `baseFrequency` so the noise itself shifts — that drift is what turns a static smear into *moving* water.
- **Pointer velocity** adds a decaying `burst` on top of the hover target, so a fast flick makes a bigger splash than a slow drift. The pointer position also feeds two CSS variables (`--mx/--my`) that place a soft "wet" specular highlight where you touch.
- The `requestAnimationFrame` loop **stops itself** once the surface settles back to `scale 0`, so an idle image costs nothing until the next hover.

## Customization

- **Ripple size** — `baseFrequency`: lower (`0.004`) = big slow swells; higher (`0.02`) = fine choppy chop. Two values (`"x y"`) make directional, current-like water.
- **Intensity** — `data-scale` (max displacement in px): `8–14` is a subtle shimmer, `30–40` is dramatic liquefaction.
- **Detail vs. cost** — `numOctaves`: `1` (cheap) → `3` (richer, pricier).
- **Flow** — the `phase += 0.02` step and the `0.20` churn factor control how fast and how much the water moves.
- **The glint** — recolour the radial-gradient stops to re-tint the wet highlight, or delete `.glint` entirely for a pure-warp look.

## Accessibility & performance

- **Progressive enhancement.** The image is plain HTML and fully visible/usable with JavaScript off or SVG filters unsupported — the warp is decorative only, so there is nothing critical to miss.
- **`prefers-reduced-motion`.** The script bails before starting any loop and forces `scale="0"` (no distortion); the glint is hidden in CSS. Always keep this branch.
- **The real cost is `feTurbulence`.** Because `baseFrequency` animates, the noise is recomputed every frame. Keep it to a couple of on-screen images, favour `numOctaves ≤ 2`, and rely on the self-stopping loop. For the cheapest variant, animate **only `scale`** and leave `baseFrequency` fixed — it still ripples, it just doesn't "flow".
- `will-change:filter` on the live image hints the GPU; drop it if you have many instances.

## Gotchas

- **Unique filter ids.** Each `<img>` needs its own filter `id`. Share one and every image displaces together (and updating one moves them all).
- **Edge fringe.** Displacement samples pixels up to `scale/2` px away, so near the border it can pull in transparency. Oversize the image (`transform:scale(1.1)`) inside an `overflow:hidden` frame so that fringe stays clipped, and give the `<filter>` a roomy region (`x/y -16%`, `width/height 132%`) so the output itself isn't cut off.
- **Selecting the primitives.** `feTurbulence`/`feDisplacementMap` are camelCase SVG names; selecting them by a **class** (`.turb`/`.disp`) is more reliable across engines than by tag name.
- **Safari/iOS** runs SVG filters on `<img>` but they are heavier — very large sources at high octaves can jank, so downscale the image. Set `color-interpolation-filters="sRGB"`, or the default `linearRGB` shifts the noise values and changes the look.
- **Only touch `scale` + `baseFrequency` per frame.** Animating the filter's blur or region every frame is the slow path; this recipe deliberately avoids it.
