MynduiMyndui
0

Anatomy of Liquid Image

How an SVG turbulence + displacement filter, a zero-rerender rAF lerp, and pointer velocity compose a liquid ripple that falls back cleanly when filters or motion are unavailable.

01/05Filter chain: noise, then displace

Watch the third plate: as scale rises, the same bands fray at the edge — that's the liquid border.

Source
the img — SourceGraphic into the filter
Noise
feTurbulence · baseFrequency drifts so it never freezes
Displaced
scale pushes pixels — edges fray into the liquid border
tsx
const id = `liquid-${React.useId().replace(/:/g, "")}`;<img style={active ? { filter: `url(#${id})` } : undefined} … /><feTurbulencetype="fractalNoise"baseFrequency={frequency}numOctaves={2}seed={2}result="noise"><animate  attributeName="baseFrequency"  dur="16s"  values={`${frequency};${frequency * 1.7};${frequency}`}  repeatCount="indefinite"/></feTurbulence><feDisplacementMapref={dispRef}in="SourceGraphic"in2="noise"scale="0"xChannelSelector="R"yChannelSelector="G"/>
01

Filter chain: noise, then displace

LiquidImage does not animate the <img> in React. It attaches an SVG feTurbulence + feDisplacementMap filter, then eases the displacement scale toward a target inside a single requestAnimationFrame loop — writing attributes directly so the image never re-renders.

A zero-size <svg> holds a <filter> whose id comes from useId() (colons stripped). Think of it as three stages of the same image:

  1. Source — the <img> enters the filter as SourceGraphic.
  2. NoisefeTurbulence builds a fractal noise field (slowly drifted by an SMIL <animate> on baseFrequency so it never freezes).
  3. DisplacedfeDisplacementMap uses that noise's R/G channels to push each source pixel, with live scale controlling how hard.

The filter's region is padded (x/y −20%, width/height 140%) so edge pixels have room to displace without clipping hard against the image bounds.

02/05Why the border boils

That irregular silhouette isn't a clip-path — it's the photo's own pixels pushed past the rect by the noise field. Higher scale → wilder border.

Noise drift
SMIL animates baseFrequency over ~16s — field never freezes
Scale
feDisplacementMap scale — how hard pixels are pushed
Padded filter
x/y −20%, 140% size — edge pixels can spill past the rect
tsx
<feTurbulence …><animate  attributeName="baseFrequency"  dur="16s"  values={`${frequency};${frequency * 1.7};${frequency}`}  repeatCount="indefinite"/></feTurbulence><feDisplacementMap … scale="0" /* rAF writes this */ />
02

Why the border boils

The irregular silhouette you see on hover isn't a separate mask or clip-path. Displacement pushes the photo's own edge pixels outward using the noise field. Two animations keep it alive:

  1. Noise drift — SMIL slowly changes baseFrequency, so the push directions keep shifting.
  2. Scale — the rAF lerp (next section) raises feDisplacementMap's scale on interaction. Higher scale → wilder fringe.
03/05The loop: lerp 0.12, no setState
requestAnimationFrame(tick)

el.setAttribute("scale", next.toFixed(2))

current
lerp: current += (target − current) × 0.12
target
hover / always / velocity write here
setAttribute
scale written on dispRef — no setState
tsx
const next = current.current + (target.current - current.current) * 0.12;current.current = next;if (el) el.setAttribute("scale", next.toFixed(2));
03

The loop: lerp 0.12, no setState

current and target live in refs. Each frame:

That is the whole animation. No React state, no style recalculation on the img — only an SVG attribute write. The loop keeps running while target > 0.01 or current > 0.05, then cancels itself so idle images cost nothing.

ensureLoop() starts a frame only when raf.current == null, so enter / move / always-mode never stack competing loops.

04/05Who writes the target
50%
45%
100%
Hover
enter → strength×0.5 · leave → 0
Always
target held at strength×0.45
Velocity
boost = min(strength, 0.5s + speed×60)
tsx
const speed = Math.hypot(e.clientX - prev.x, e.clientY - prev.y) / dt;const boost = Math.min(strength, strength * 0.5 + speed * 60);if (trigger === "hover" || boost > target.current) {target.current = boost;ensureLoop();}
04

Who writes the target

Three writers feed the same lerp:

Mode / eventTarget
trigger="hover" enterstrength * 0.5
trigger="hover" leave0
trigger="always"strength * 0.45
Pointer move (velocity)min(strength, strength * 0.5 + speed * 60)

Velocity is hypot(Δx, Δy) / dt with a 16ms floor on dt. Faster cursors spike the ripple briefly; the lerp settles it back. On always, boosts only raise the target when they exceed the current hold.

05/05Result
Mountain landscape
River valley
Waterfall
05

The result

One filter id, one rAF lerp, three target writers — and a CSS fallback when the GPU path is unavailable.

Fallback: unsupported filter or reduced motion

active = supported && !reduce. Support is probed once with CSS.supports("filter", "url(#a)"). When inactive, the SVG filter is omitted and the img gets a cheap CSS hover: scale(1.04) + brightness(105%), gated by motion-reduce: utilities so reduced-motion users see a still image.

tsx
 
className={`… ${
  active
    ? ""
    : "[transition:transform_500ms_ease,filter_500ms_ease] group-hover:scale-[1.04] group-hover:brightness-105 motion-reduce:transform-none motion-reduce:transition-none"
}`}

Motion Score

Liquid ImageCCPaint-triggering
StransformPointer-driven ripple
CSVG displacementDisplacement map driven each frame
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →