"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(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 (
{/* eslint-disable-next-line @next/next/no-img-element */} {alt}
); }