GodUIGodUI
129Follow on X

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, so 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 attaches an SVG feTurbulence + feDisplacementMap filter to the <img>, then eases the displacement scale toward a target inside a single requestAnimationFrame loop. It writes attributes directly, so React never re-renders the image.

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. Noise: feTurbulence builds a fractal noise field (slowly drifted by an SMIL <animate> on baseFrequency so it never freezes).
  3. Displaced: feDisplacementMap 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 is the photo's own pixels pushed past the rect by the noise field, not a clip-path. Higher scale → wilder border.

Noise drift
SMIL animates baseFrequency over ~16s, so the field never freezes
Scale
feDisplacementMap scale: how hard pixels are pushed
Padded filter
x/y −20%, 140% size, so 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 comes from displacement, with no separate mask or clip-path: it 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 moves current 12% of the way to target and writes the result to feDisplacementMap's scale attribute.

That is the whole animation. There is no React state and 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 for 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 ImageCC: Paint-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 →