Image Effects
LiquidImage
The picture sits under water. It swells gently, swells more on hover, and a click or tap drops a ring into it. WebGL, runs only while on screen.
Download
- React
- Tailwind CSS v4
- motion
components/ui/liquid-image.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type LiquidImageProps = {
src: string;
alt: string;
/** Size of the waves. 1 is a soft swell. */
waves?: number;
/** Set the size here. */
className?: string;
};
const DROPS = 3;
const REST = 0.4;
const VERTEX = `attribute vec2 p;varying vec2 v;void main(){v=p*.5+.5;gl_Position=vec4(p,0.,1.);}`;
const FRAGMENT = `precision highp float;
varying vec2 v;
uniform sampler2D t;
uniform vec2 fit;
uniform float u,amp,box;
uniform vec3 drops[${DROPS}];
void main(){
vec2 o=vec2(sin(v.y*9.+u*1.1)+sin(v.y*17.-u*.7)*.5,cos(v.x*8.-u*.9)+sin((v.x+v.y)*13.+u*.6)*.5)*.0075*amp;
for(int i=0;i<${DROPS};i++){
vec2 q=(v-drops[i].xy)*vec2(box,1.);
float r=max(length(q),.0001);
float gap=r-drops[i].z*.7;
o+=q/r*sin(gap*55.)*exp(-gap*gap*90.)*exp(-drops[i].z*2.4)*.035;
}
gl_FragColor=texture2D(t,((v+o)-.5)*fit*.965+.5);
}`;
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* LiquidImage: the picture sits under water. It swells gently, swells more on hover, and a click or tap drops a ring into it. Runs only while on screen. */
export function LiquidImage({ src, alt, waves = 1, className = "aspect-video" }: LiquidImageProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const box = boxRef.current;
if (!box || reduce) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const made = gl && program(gl);
if (!gl || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
const drops = Array.from({ length: DROPS }, () => ({ x: 0, y: 0, born: -100 }));
let nextDrop = 0;
let raf = 0;
let ready = false;
let inView = false;
let amp = REST;
let goal = REST;
const image = new Image();
image.crossOrigin = "anonymous";
const draw = (now: number) => {
const seconds = now / 1000;
amp += (goal - amp) * 0.06;
gl.uniform1f(at("u"), seconds % 1000);
gl.uniform1f(at("amp"), amp * waves);
gl.uniform3fv(at("drops"), drops.flatMap((d) => [d.x, d.y, seconds - d.born]));
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
draw(now);
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (ready && inView && !raf) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
if (!ready) return;
const shape = width / height / (image.naturalWidth / image.naturalHeight);
gl.uniform2f(at("fit"), Math.min(1 / shape, 1), Math.min(shape, 1));
gl.uniform1f(at("box"), width / height);
draw(performance.now());
};
const drop = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
drops[nextDrop] = { x: (e.clientX - r.left) / r.width, y: 1 - (e.clientY - r.top) / r.height, born: performance.now() / 1000 };
nextDrop = (nextDrop + 1) % DROPS;
};
const enter = (e: PointerEvent) => {
if (e.pointerType === "mouse") goal = 1;
};
const leave = () => {
goal = REST;
};
image.onload = () => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
for (const wrap of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, wrap, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
ready = true;
fit();
box.append(canvas);
start();
};
image.src = src;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(box);
const lost = () => canvas.remove();
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointerdown", drop);
box.addEventListener("pointerenter", enter);
box.addEventListener("pointerleave", leave);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
image.onload = null;
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointerdown", drop);
box.removeEventListener("pointerenter", enter);
box.removeEventListener("pointerleave", leave);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
};
}, [src, waves, reduce]);
return (
<div ref={boxRef} className={`relative overflow-hidden ${className}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} className="absolute inset-0 size-full object-cover" />
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/liquid-image.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/liquid-image-demo.tsx
import { LiquidImage } from "@/components/ui/liquid-image"; export default function LiquidImageExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <LiquidImage src="/demo/dusk.jpg" alt="Hills at dusk under a pale sun" className="aspect-video w-full max-w-[520px] rounded-2xl" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. From another site it needs CORS headers, or the plain picture shows with no effect. |
| alt | string | — | Text for screen readers. |
| waves | number | 1 | Size of the waves. 1 is a soft swell. |
| className | string | "aspect-video" | Set the size here. |