Baktiyar Ablaikhan

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).