Media

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type LiquidDistortionTrigger = "hover" | "always"

/** A backgrounded tab resumes with a huge gap — clamp so the field never jumps. */
const MAX_DT = 1 / 30
/** Seconds for the ripple to bite once the pointer arrives. Rising is deliberately
 *  faster than settling: the image has to answer the cursor, then relax. */
const RISE = 0.09
/** Exponential easing covers ~98% in 4 time constants, so `settle` / 4 is the tau. */
const SETTLE_TAU = 4

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/liquid-distortion-image.json

Prompt

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 feOffset translates 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.

On This Page