"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type LiquidMetalProps = { /** Six-digit hex colour the chrome leans toward. */ tint?: string; /** How much of that colour shows, 0 to 1. */ tintAmount?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** Name read out by screen readers. */ label?: string; /** Set the size here. */ className?: string; }; const DROPS = 5; const MAX_PIXELS = 900000; const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`; const FRAGMENT = `precision highp float; uniform vec2 res; uniform float time,amount; uniform vec3 tint; uniform vec4 drops[${DROPS}]; float map(vec3 p){ float d=9.; for(int i=0;i<${DROPS};i++){ float b=length(p-drops[i].xyz)-drops[i].w; float h=clamp(.5+.5*(b-d)/.62,0.,1.); d=mix(b,d,h)-.62*h*(1.-h); } return d+sin(p.x*5.+time)*sin(p.y*4.-time*.8)*sin(p.z*5.+time*.6)*.022; } vec3 normal(vec3 p){ vec2 e=vec2(.005,-.005); return normalize(e.xyy*map(p+e.xyy)+e.yyx*map(p+e.yyx)+e.yxy*map(p+e.yxy)+e.xxx*map(p+e.xxx)); } vec3 room(vec3 r){ float w=r.y+sin(r.x*3.+time*.3)*.06+sin(r.z*5.-time*.2)*.025; vec3 c=w>0.?mix(vec3(1.),vec3(.1,.11,.2),pow(min(w*1.3,1.),.55)):mix(vec3(.02,.02,.04),vec3(.72,.7,.8),pow(min(-w*1.25,1.),1.6)); vec2 a=(r.xy-vec2(.46,.56))*vec2(4.2,8.),b=(r.xy-vec2(-.74,.18))*vec2(9.,3.2); c+=vec3(1.)*(exp(-dot(a,a)*dot(a,a))+.8*exp(-dot(b,b)*dot(b,b))); return c; } void main(){ float side=min(res.x,res.y); vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*side); vec3 o=vec3(0.,0.,5.),d=normalize(vec3(uv,-3.)); float t=2.,best=1.,bestT=t; for(int i=0;i<56;i++){ float m=map(o+d*t); if(m8.)break; t+=m; } if(best<.0002)bestT=t; float a=1.-smoothstep(0.,4.5/side,best); if(a<=0.){gl_FragColor=vec4(0.);return;} vec3 p=o+d*bestT,n=normal(p),r=reflect(d,n); vec3 col=room(r); col*=mix(vec3(1.),tint*1.25,amount); col+=pow(1.+dot(d,n),4.)*mix(vec3(.7),tint,amount)*.5; gl_FragColor=vec4(min(col,vec3(1.))*a,a); }`; const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); 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; } /* LiquidMetal: drops of mirror-bright metal merge and split, and one reaches for the pointer. Runs only while on screen. */ export function LiquidMetal({ tint = "#a78bfa", tintAmount = 0.25, speed = 1, label = "Drops of liquid metal", className = "aspect-square w-80", }: LiquidMetalProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); useEffect(() => { const box = boxRef.current; const plain = plainRef.current; if (!box || !plain) 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: true, 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); gl.uniform3fv(at("tint"), rgb(tint)); gl.uniform1f(at("amount"), Math.max(0, Math.min(1, tintAmount))); let raf = 0; let last = 0; let inView = false; let time = reduce ? 4 : 0; let goalX = 0; let goalY = 0; let handX = 0; let handY = 0; let reach = 0; let reachGoal = 0; const draw = () => { const t = time * 0.55; const drift = [ [Math.sin(t * 1.1) * 0.5, Math.cos(t * 0.9) * 0.4], [Math.cos(t * 0.8 + 2) * 0.72, Math.sin(t * 1.2 + 1) * 0.55], [Math.sin(t * 0.7 + 4) * 0.8, Math.cos(t * 1.05 + 3) * 0.66], [Math.cos(t * 1.3 + 5) * 0.62, Math.sin(t * 0.75 + 2) * 0.75], ]; const list = [handX * reach, handY * reach, 0, 0.42]; drift.forEach(([x, y], i) => list.push(x, y, Math.sin(t + i * 2) * 0.3, [0.68, 0.46, 0.38, 0.32][i])); gl.uniform1f(at("time"), time % 1000); gl.uniform4fv(at("drops"), list); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; time += dt * speed; handX += (goalX - handX) * 0.09; handY += (goalY - handY) * 0.09; reach += (reachGoal - reach) * 0.08; draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const start = () => { if (inView && !raf && !reduce) raf = requestAnimationFrame(frame); }; const fit = () => { const { width, height } = box.getBoundingClientRect(); if (!width || !height) return; const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height))); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(at("res"), canvas.width, canvas.height); draw(); }; const point = (e: PointerEvent) => { const r = box.getBoundingClientRect(); const side = Math.min(r.width, r.height); goalX = ((e.clientX - r.left - r.width / 2) / side) * 3.8; goalY = -((e.clientY - r.top - r.height / 2) / side) * 3.8; reachGoal = 1; }; const leave = () => { reachGoal = 0; }; fit(); box.append(canvas); plain.hidden = true; const sized = new ResizeObserver(fit); sized.observe(box); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; start(); }); watch.observe(box); const lost = () => { canvas.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); box.addEventListener("pointermove", point); box.addEventListener("pointerleave", leave); return () => { cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("pointermove", point); box.removeEventListener("pointerleave", leave); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [tint, tintAmount, speed, reduce]); return (
); }