Liquid Distortion Image
A picture that ripples where the pointer touches it — an SVG feTurbulence + feDisplacementMap twin, masked to a soft lens that follows the cursor and settles back when it leaves.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/liquid-distortion-image.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "LiquidDistortionImage" component — a photo
that ripples where the pointer touches it. Its only dependency is a cn() class
merger (clsx + tailwind-merge); the distortion is an inline SVG filter animated
from JavaScript, with no animation library, no canvas and no WebGL. It uses
hooks, pointer events and matchMedia, so it is a client component ("use client").
Contract
- export const LiquidDistortionImage = React.forwardRef<HTMLDivElement,
LiquidDistortionImageProps>(...) with props extending
React.HTMLAttributes<HTMLDivElement> (rest props spread on the root, ref
forwarded to it through useImperativeHandle over an internal ref):
- src: string (required) — used by BOTH image layers, so it is fetched once.
- alt: string (required) — pass "" when the picture is decorative.
- strength?: number (default 26) — peak feDisplacementMap scale in px, i.e. how
far the liquid may drag a pixel. Clamp to >= 0.
- frequency?: number (default 0.014) — feTurbulence baseFrequency: the SIZE of
the ripples, not their depth. Clamp to 0.0005..0.5.
- radius?: number (default 170) — radius of the pointer lens in px. Ignored when
trigger="always", which displaces the whole picture; say so in the JSDoc.
Clamp to >= 24, or the lens mask shrinks to nothing and the effect silently
disappears.
- settle?: number (default 650) — milliseconds for the ripple to flatten out
after the pointer leaves. Hover mode only. Clamp to >= 80ms.
- trigger?: "hover" | "always" (default "hover") — a lens that follows the
pointer, or a continuous churn over the whole image that ignores the pointer.
- imageClassName?: string — applied to BOTH layers so they stay pixel-aligned
(object-contain, object-top, …).
- children — optional overlay (badge / headline / CTA), rendered in an
`absolute inset-0 z-10` layer above the picture.
Behavior
- Two layers, one URL. The base <img> is the real, untouched picture. A second
copy sits on top, aria-hidden, alt="", pointer-events-none, absolute inset-0,
carrying `filter: url(#id)`. Displacement drags pixels out of place and pulls
transparency in from outside the box — the pristine copy underneath is what
shows through, so the effect has no holes and no seams, and a browser that
declines to apply an SVG filter to an HTML element still shows a correct photo.
- The filter, in one inline <svg class="absolute size-0" aria-hidden>:
feTurbulence(type="turbulence", baseFrequency=frequency, numOctaves=2, fixed
seed) -> feOffset(dx, dy) -> feDisplacementMap(in="SourceGraphic", in2=offset
result, xChannelSelector="R", yChannelSelector="G", scale=0 at rest). Pad the
region by a quarter of the box on every side (x/y -25%, width/height 150%): the
offset slides the noise field, and that padding is what keeps the field's own
transparent edge — which displaces as a hard band — outside the picture. Set
colorInterpolationFilters="sRGB" so the photo keeps its colours. The id comes
from useId with non-id characters stripped, so several images on one page never
share a filter.
- Animate the FILTER VALUES, not a CSS property: one requestAnimationFrame loop
setAttribute()s dx/dy on the feOffset and scale on the feDisplacementMap. Slide
the noise field (dx = sin(t*0.62)*22, dy = cos(t*0.41)*22) instead of retuning
baseFrequency — the field then FLOWS rather than boiling in place, and the
turbulence stays cacheable. Keep the flow clock in a ref so a loop restart never
teleports the field, and clamp dt to 1/30s for tab returns.
- One eased amplitude drives everything: level += (target - level) *
(1 - exp(-dt / tau)), with target 1 while active and 0 otherwise. Rising uses a
fixed fast tau (~0.09s) so the picture answers the cursor immediately; only the
way back uses `settle` (tau = settle / 4, since exponential easing covers ~98%
in four time constants). Displacement scale is strength * level.
- The lens (trigger="hover"): the top layer is masked with
radial-gradient(circle closest-side, currentColor, currentColor 22%, transparent),
mask-repeat: no-repeat, and the rAF loop writes only mask-size and mask-position
(both the standard and the -webkit- longhands, via setProperty). Size is
radius*2 * (0.35 + 0.65*level), so a released lens SHRINKS away instead of
fading at full size; position is the eased lens centre minus half the size.
Always write both mask-size axes — a gradient has no intrinsic size, so a single
value leaves the height at 100% and turns the disc into a stripe. Only the alpha
of that gradient is read, so currentColor is a channel here, not a colour; pin
the layer to a foreground token to guarantee it is opaque.
- Pointer: pointerenter / pointermove / pointerleave / pointercancel are PASSIVE
listeners on the root that only store clientX/clientY and wake the loop. Every
rect read, every ease and every style write happens inside the rAF frame, so a
240Hz pointer stream costs one paint per frame and can never block a scroll. The
lens eases toward the pointer with a ~0.05s lag (a little drag reads as weight)
but SNAPS on entry, so it does not slide across the picture from where it was
left. When released it stays put and shrinks, rather than gliding to the middle.
- Keyboard parity: the root listens for focusin/focusout — which bubble, so
focusing a CTA inside the overlay ripples the image too, centred in the frame.
The root itself is NOT tabbable and gets no tabIndex: the ripple is purely
ornamental and announces nothing, so a tab stop there would be a silent,
roleless, nameless stop on the way to something that matters.
- trigger="always": no listeners at all, no mask, level pinned at 1 — the whole
picture churns continuously. `radius` and `settle` are inert here.
- Suspension and cleanup: an IntersectionObserver stops the loop off screen,
visibilitychange stops it in a hidden tab, and in hover mode the loop CLOSES
itself once the ripple has settled (level 0, layer set to visibility: hidden, so
a resting image costs exactly one <img> again); the pointer and focus handlers
reopen it. On unmount cancel the rAF, disconnect the observer and remove every
listener.
- Capability gates, read through matchMedia + useSyncExternalStore so a
mid-session change is honoured and the listener tears itself down:
prefers-reduced-motion: reduce renders the plain image and never mounts the
filter or the twin at all; (pointer: coarse) does the same in BOTH modes —
there is no hover on touch, and trigger="always" would otherwise run a
full-size turbulence + displacement graph at 60fps on a phone for as long as
the picture is on screen. The picture is fully visible and fully usable in
every one of those states.
- SSR: nothing touches window or document during render; the media stores return
false on the server, the twin renders with scale 0 and visibility: hidden, so
the server HTML and the first paint are just the photograph.
Rendering & styling
- Root: `relative isolate overflow-hidden` + cn(className); the ROOT is the box,
so the consumer sizes it (h-[420px], aspect-[16/9], rounded-xl border). Both
layers are `block size-full object-cover` plus imageClassName.
- Semantic tokens only — text-foreground for the mask's opaque currentColor, and
any overlay scrim built from background/foreground tokens. No hex and no raw
colour-function literals,
so the frame re-skins itself with the host theme and dark mode.
- Accessibility: the base <img> carries the real alt; the distorted twin is
aria-hidden with alt="" and pointer-events-none; the decorative <svg> is
aria-hidden and focusable="false". Overlay children stay interactive and
keyboard reachable.
Customization levers
- Character: `strength` is amplitude (10 = heat haze, 26 = water under glass,
70 = melt) and `frequency` is ripple size (0.004 = one long swell, 0.05 =
frosted glass). They are independent — tune amplitude first, then scale.
- Lens feel: `radius` (droplet vs half the card), the LENS_CORE / LENS_FLOOR
constants (how much of the disc stays at full strength, and how small it closes
to) and LENS_LAG (0 = glued to the cursor, 0.12 = syrupy).
- Timing: `settle` for the release, the RISE constant for the attack, and
FLOW / FLOW_X / FLOW_Y for how fast and how far the noise field drifts.
- Mode: trigger="always" turns it into an ambient churn for a background plate;
keep it on a low `strength` there, because nobody is asking for it.
- Texture: swap feTurbulence type="turbulence" for "fractalNoise" (softer, more
cloudlike), raise numOctaves for finer structure at a real cost, or animate
baseFrequency alongside the offset for a boil on top of the flow.
- Shape and content: the root's rounding/aspect clip the effect to any silhouette,
and `children` gives you the overlay — badge, headline, CTA — over the ripple.
- Same recipe, other subjects: point `src` at a texture or a logo plate instead of
a photo, or raise `strength` sharply for a deliberately unreadable teaser.Concepts
- Twin, not replacement — the pristine photo stays underneath and the filtered copy is layered on top. Displacement pulls transparency in from outside the box, and what shows through the holes is the correct picture, so the effect can never leave the image broken — including in a browser that ignores the filter entirely.
- Sliding noise field — the liquid flows because
feOffsettranslates the turbulence, not because the turbulence is regenerated: the noise stays seeded and cacheable, and the ripples travel instead of boiling in place. - Lens as a mask — "ripples where the pointer touches" is a mask, not a second filter. One soft radial disc over the filtered layer decides where the distortion is visible, and it shrinks as the ripple settles so a released lens closes rather than blinking out.
- Asymmetric easing — one amplitude drives the whole effect, but it rises on a fixed fast time constant and falls on
settle. A picture that is slow to react feels broken; a picture that is slow to relax feels like liquid. - Loop that closes — a settled image has nothing left to animate, so the rAF loop cancels itself and the layer goes
visibility: hidden; the pointer and focus handlers are what reopen it. Idle cards on a long page cost nothing. - Capability, not decoration, decides — reduced motion and coarse pointers do not get a weaker ripple, they get no extra layer at all: the plain, fully readable photograph is the fallback, and a mid-session system change swaps between them without a reload.
Scroll Scrubbed Video
A pinned section whose scroll distance drives the video playhead — rAF-coalesced seeks, readyState-guarded, collapsing to a poster and a play control when motion or seeking is off the table.
Image Accordion
A row of tall image slices where the open panel widens to reveal its caption while the rest compress to labelled slivers.