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→