Liquid Chrome Word
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
The word metal rendered as flowing liquid chrome; moving the cursor tilts the reflection and clicking changes the colourway.
Code
"use client";
import { approach, hex, makeCanvas, useStage, type Scene } from "./stage";
/*
* "metal" as flowing liquid chrome: a height field built from blurred copies of
* the word is shaded in a fragment shader that reflects a banded studio sky.
* The pointer tilts the reflection; a click changes the colourway.
*/
const WORD = "metal";
// sky, horizon, ground, glow
const WAYS: [string, string, string, string][] = [
["#f2fdff", "#63bdd0", "#0b1217", "#2a6f80"],
["#fff4cf", "#d9a33a", "#1f1305", "#8a5a12"],
["#ffe6f1", "#d86b95", "#24070f", "#7d2747"],
["#efe8ff", "#8c6cf2", "#0f0a22", "#4a33a8"],
["#ffffff", "#a3a8b0", "#0b0c0e", "#4b5058"],
];
const VERT = `attribute vec2 aPos; varying vec2 vUv;
void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`;
const FRAG = `precision mediump float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uPx;
uniform float uTime;
uniform vec2 uTilt;
uniform vec3 uSky, uHor, uGround, uGlow;
float hgt(vec2 uv){ vec4 m = texture2D(uTex, uv); return m.r * 0.25 + m.g * 0.5 + m.b * 0.25; }
void main(){
vec4 m = texture2D(uTex, vUv);
vec2 e = uPx * 1.5;
float hx = hgt(vUv + vec2(e.x, 0.0)) - hgt(vUv - vec2(e.x, 0.0));
float hy = hgt(vUv + vec2(0.0, e.y)) - hgt(vUv - vec2(0.0, e.y));
vec3 n = normalize(vec3(-hx * 9.0, -hy * 9.0, 0.35 + 0.65 * m.b));
// liquid flow: a slow travelling ripple in the normal
float t = uTime;
n.xy += 0.10 * vec2(
sin(vUv.y * 21.0 + t * 1.4 + sin(vUv.x * 9.0 - t * 0.7) * 1.6),
cos(vUv.x * 17.0 - t * 1.1 + sin(vUv.y * 11.0 + t * 0.9) * 1.3)
) * m.g;
n = normalize(n);
vec3 r = reflect(vec3(0.0, 0.0, -1.0), n);
float k = r.y * 0.62 + 0.5 + uTilt.y * 0.35 + r.x * uTilt.x * 0.45;
vec3 env;
if (k > 0.5) {
env = mix(uHor, uSky, smoothstep(0.5, 0.95, k));
env += 0.35 * smoothstep(0.82, 0.98, k);
} else {
env = mix(uGround, uHor * 0.45, smoothstep(0.05, 0.48, k));
env *= 1.0 - 0.75 * smoothstep(0.42, 0.5, k);
}
vec3 L = normalize(vec3(uTilt * 1.4 + vec2(-0.3, 0.45), 1.0));
float spec = pow(max(dot(n, L), 0.0), 60.0);
env += spec * 0.9;
float rim = smoothstep(0.25, 0.0, m.g) * m.r;
env = mix(env, uSky, rim * 0.4);
vec3 bg = vec3(0.035, 0.04, 0.048) + uGlow * (0.22 * m.b * (1.0 - m.r));
float vig = smoothstep(1.25, 0.2, length((vUv - 0.5) * vec2(1.6, 1.0)));
bg *= 0.6 + 0.6 * vig;
vec3 col = mix(bg, env, smoothstep(0.35, 0.65, m.r));
gl_FragColor = vec4(col, 1.0);
}`;
type GL = {
canvas: HTMLCanvasElement;
gl: WebGLRenderingContext;
prog: WebGLProgram;
tex: WebGLTexture;
u: Record<string, WebGLUniformLocation | null>;
};
function initGL(): GL | null {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl", { preserveDrawingBuffer: true, antialias: false, premultipliedAlpha: false });
if (!gl) return null;
const sh = (type: number, src: string) => {
const s = gl.createShader(type);
if (!s) return null;
gl.shaderSource(s, src);
gl.compileShader(s);
return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
};
const vs = sh(gl.VERTEX_SHADER, VERT);
const fs = sh(gl.FRAGMENT_SHADER, FRAG);
const prog = gl.createProgram();
if (!vs || !fs || !prog) return null;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const tex = gl.createTexture();
if (!tex) return null;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const u: GL["u"] = {};
for (const name of ["uTex", "uPx", "uTime", "uTilt", "uSky", "uHor", "uGround", "uGlow"]) u[name] = gl.getUniformLocation(prog, name);
return { canvas, gl, prog, tex, u };
}
function fontFor(h: number, w: number) {
const size = Math.min(h * 0.42, w * 0.25);
return { size, css: `700 ${size}px "Avenir Next", "Helvetica Neue", "Segoe UI", Arial, sans-serif` };
}
/** R = sharp mask, G = medium blur, B = wide blur, packed into one texture. */
function buildMask(W: number, H: number) {
const layer = (blur: number) => {
const { ctx } = makeCanvas(W, H);
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, W, H);
const f = fontFor(H, W);
ctx.font = f.css;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#fff";
if (blur > 0) {
ctx.shadowColor = "#fff";
ctx.shadowBlur = blur;
ctx.shadowOffsetX = W * 2;
ctx.fillText(WORD, W / 2 - W * 2, H / 2);
} else ctx.fillText(WORD, W / 2, H / 2);
return ctx.getImageData(0, 0, W, H).data;
};
const r = layer(0);
const g = layer(H * 0.035);
const b = layer(H * 0.11);
const out = new Uint8Array(W * H * 4);
for (let i = 0; i < W * H; i++) {
out[i * 4] = r[i * 4];
// keep the inner bevel inside the letter: the blurred copy only counts where the word is
out[i * 4 + 1] = Math.min(255, g[i * 4] * 1.6);
out[i * 4 + 2] = Math.min(255, b[i * 4] * 1.5);
out[i * 4 + 3] = 255;
}
return out;
}
function createScene(): Scene {
const g = initGL();
let way = 0;
const tilt = { x: 0, y: 0 };
let tw = 0;
let th = 0;
return {
up(p) {
if (p.inside) way = (way + 1) % WAYS.length;
},
cursor: () => "pointer",
resize(w, h, dpr) {
if (!g) return;
const s = Math.min(1.5, dpr);
tw = Math.round(w * s);
th = Math.round(h * s);
g.canvas.width = tw;
g.canvas.height = th;
const gl = g.gl;
gl.viewport(0, 0, tw, th);
gl.bindTexture(gl.TEXTURE_2D, g.tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, tw, th, 0, gl.RGBA, gl.UNSIGNED_BYTE, buildMask(tw, th));
},
frame({ ctx, w, h, t, dt, p }) {
const tx = p.inside ? (p.x / w - 0.5) * 2 : Math.sin(t * 0.4) * 0.5;
const ty = p.inside ? (0.5 - p.y / h) * 2 : Math.cos(t * 0.31) * 0.35;
tilt.x = approach(tilt.x, tx, 4, dt);
tilt.y = approach(tilt.y, ty, 4, dt);
const c = WAYS[way];
if (!g || !tw) {
const f = fontFor(h, w);
ctx.fillStyle = "#0a0b0d";
ctx.fillRect(0, 0, w, h);
const gr = ctx.createLinearGradient(0, h * 0.3, 0, h * 0.7);
gr.addColorStop(0, c[0]);
gr.addColorStop(0.5 + tilt.y * 0.1, c[1]);
gr.addColorStop(1, c[2]);
ctx.font = f.css;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = gr;
ctx.fillText(WORD, w / 2, h / 2);
return;
}
const gl = g.gl;
const v = (s: string) => hex(s).map((n) => n / 255) as [number, number, number];
gl.uniform1i(g.u.uTex, 0);
gl.uniform2f(g.u.uPx, 1 / tw, 1 / th);
gl.uniform1f(g.u.uTime, t);
gl.uniform2f(g.u.uTilt, tilt.x, tilt.y);
gl.uniform3fv(g.u.uSky, v(c[0]));
gl.uniform3fv(g.u.uHor, v(c[1]));
gl.uniform3fv(g.u.uGround, v(c[2]));
gl.uniform3fv(g.u.uGlow, v(c[3]));
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
ctx.drawImage(g.canvas, 0, 0, w, h);
},
dispose() {
g?.gl.getExtension("WEBGL_lose_context")?.loseContext();
},
};
}
export default function LiquidMetal() {
const ref = useStage(createScene, 1.2);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#0a0b0d]" />;
}
Prompt
Build "Liquid Chrome Word", a text animation. What it does: The word metal rendered as flowing liquid chrome; moving the cursor tilts the reflection and clicking changes the colourway. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, WebGL, a requestAnimationFrame loop, pointer tracking. It sits on a dark background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).