3D & Experimental
LiquidMetal
Drops of mirror-bright metal merge and split, and one reaches for the pointer. Clear background, so it sits on anything. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/liquid-metal.tsx
"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(m<best){best=m;bestT=t;}
if(m<.0002||t>8.)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<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(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 (
<div ref={boxRef} role="img" aria-label={label} className={`relative overflow-hidden ${className}`}>
<div
ref={plainRef}
className="absolute left-1/2 top-1/2 aspect-square h-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: `radial-gradient(circle at 35% 30%, #fff, #8a8a99 45%, ${tint} 100%)` }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/liquid-metal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/liquid-metal-demo.tsx
import { LiquidMetal } from "@/components/ui/liquid-metal"; export default function LiquidMetalExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-2"> <LiquidMetal className="aspect-square h-full max-h-[400px]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| tint | string | "#a78bfa" | Six-digit hex colour the chrome leans toward. |
| tintAmount | number | 0.25 | How much of that colour shows, 0 to 1. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Drops of liquid metal" | Name read out by screen readers. |
| className | string | "aspect-square w-80" | Set the size here. |