Skip to content
ARDENT / MOTION LAB
Library

3D / Experimental · Advanced

Hover Image Distortion

An feTurbulence + feDisplacementMap filter is applied to the image; a spring animates the displacement scale in and out. A WebGL shader would be smoother at scale—this is the dependency-free version.

Live preview

Hover the image with a mouse, or Tab to focus it.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Hover Distortion
 * A uniquely scoped SVG displacement filter gives imagery a liquid edge.
 * Requires: motion, Tailwind with shadcn semantic tokens.
 */
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

export default function HoverDistortion({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useReducedMotion();
  const reduced = reducedProp ?? systemReduced ?? false;
  const amount = Math.min(1.5, Math.max(0.5, intensity));
  const id = `ardent-distortion-${useId().replace(/:/g, "")}`;
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [selected, setSelected] = useState(false);
  const active = !reduced && (hovered || focused);

  return (
    <section className="flex min-h-[80vh] items-center justify-center bg-background px-6 py-20 text-foreground">
      <svg width="0" height="0" aria-hidden="true" className="absolute">
        <defs>
          <filter id={id} x="-15%" y="-15%" width="130%" height="130%">
            <feTurbulence
              type="fractalNoise"
              baseFrequency="0.012 0.028"
              numOctaves={2}
              seed={8}
              result="noise"
            />
            <motion.feDisplacementMap
              in="SourceGraphic"
              in2="noise"
              xChannelSelector="R"
              yChannelSelector="G"
              initial={false}
              animate={{ scale: active ? 24 * amount : 0 }}
              transition={{ duration: reduced ? 0 : 0.65, ease: [0.16, 1, 0.3, 1] }}
            />
          </filter>
        </defs>
      </svg>
      <figure className="w-full max-w-xl">
        <p className="mb-6 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
          Surface studies / 08
        </p>
        <button
          type="button"
          aria-label="Select the surface study"
          aria-pressed={selected}
          onClick={() => setSelected((value) => !value)}
          onPointerEnter={(event) => {
            if (
              event.pointerType === "mouse" &&
              window.matchMedia("(hover: hover) and (pointer: fine)").matches
            )
              setHovered(true);
          }}
          onPointerLeave={() => setHovered(false)}
          onPointerCancel={() => setHovered(false)}
          onFocus={() => setFocused(true)}
          onBlur={() => setFocused(false)}
          className="block aspect-[4/5] max-h-[65vh] w-full overflow-hidden rounded-2xl border border-border focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4"
        >
          <img
            src="https://picsum.photos/seed/ardent-surface/900/1100"
            alt="Photographic surface study with a soft liquid displacement effect."
            className="h-full w-full scale-110 object-cover"
            style={{ filter: reduced ? undefined : `url(#${id})` }}
          />
        </button>
        <figcaption className="mt-6 flex items-end justify-between gap-4">
          <h2 className="font-serif text-4xl tracking-tight">Nothing stays still.</h2>
          <span aria-live="polite" className="text-sm text-muted-foreground">
            {selected ? "Selected" : "Study 008"}
          </span>
        </figcaption>
      </figure>
    </section>
  );
}
Next patternAnimated Bento Grid→