Skip to main content

ns-ui / loud

Weld Pool

A full-bleed liquid-metal hero whose headline is summed into the shader's height field rather than layered over it, so the letterforms are lit, warped and dented by the same molten surface they sit in.

Use when Pick weld-pool when the headline itself should be the material — the type is rasterized into a liquid-metal height field and lit by the same studio environment as the surface, so it warps, glints and dents under the pointer. Pick border-chrome-ring instead when you want specular metal as a frame around existing content rather than a full-bleed field; background-gradient-shader when you want a calm coloured backdrop the type simply sits on top of.

Install

npx shadcn add https://design.helpmarq.com/r/weld-pool.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/loud/weld-pool/component.tsx
"use client";

import { useEffect, useId, useRef } from "react";

// ---------------------------------------------------------------------------
// WeldPool — a full-bleed molten-metal hero with the headline lying IN the
// surface rather than on top of it.
//
// One WebGL fragment shader owns the whole viewport. It builds a height field
// h(x,y,t) out of four superposed contributions — a domain-warped fbm flow
// (the convecting bulk), four drifting gaussian lobes (buoyant volumes rolling
// under the skin), a high-frequency advected grain (polish micro-structure),
// and a rasterized glyph bevel — then central-differences that single field to
// get one surface normal. Because the letters are summed into the same field
// as the flow, they are lit by the same environment, distorted by the same
// warp and dented by the same pointer: the type is part of the metal, not a
// label over it.
//
// The metal read comes from the environment, not from the material. The
// reflection vector is looked up in an analytic achromatic studio: a broad sky,
// a dark floor, four narrow strip lights at different elevations and two slowly
// turning vertical panels. Density is bought from the room having structure
// rather than from the height field carrying more noise — an almost-flat patch
// of surface still crosses three or four reflection bands, which is what a real
// chrome sheet does. That is what produces the near-black to
// near-white value range chrome needs. Since the palette is monochrome by
// constraint, every cue that would normally be carried by hue is carried by
// value instead — tight specular lobes, a hard horizon line, fine polish
// banding, brushed streaks, a fresnel rim and a contact shadow around each
// glyph. --ns-accent only tints the hottest specular pixels, at ~13%.
//
// Palette: five luminance stops derived from --background, --foreground,
// --ns-muted and --border (getComputedStyle at mount, re-read on a
// MutationObserver watching documentElement's class). Unlike a thin metal
// band on a page, a full-bleed sheet IS the page, so the ramp spans the full
// range in BOTH themes — what changes between them is the distribution (bias
// and contrast), not the direction. Light theme is polished steel under a
// bright room; dark theme is the same pool lit by the same room, sunk.
// ---------------------------------------------------------------------------

export interface WeldPoolProps {
  /** Headline rasterized into the metal. "\n" splits lines. */
  headline?: string;
  /** Glyph weight for the rasterized headline. @default 600 */
  headlineWeight?: number;
  /** Fraction of the container width the longest headline line fills. @default 0.82 */
  headlineFit?: number;
  /** Vertical centre of the headline block, 0 = top, 1 = bottom. @default 0.44 */
  headlineY?: number;
  /** How far the glyphs stand out of the pool, 0..1. @default 1 */
  relief?: number;
  /** Flow speed multiplier. @default 1 */
  speed?: number;
  /** Feature size of the convecting bulk — larger reads as a tighter boil. @default 1 */
  scale?: number;
  /** Freezes the surface on a composed still frame without unmounting. */
  paused?: boolean;
  /** Rendered in the DOM over the pool — eyebrow, subhead, CTA. */
  children?: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

const VERT_SRC = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;

const TRAIL = 20;
const LOBES = 4;

// Pointer smoothing / wake sampling. The wake is a train of rings laid down
// along the pointer's path; these govern how that path is sampled, and they
// are the whole difference between a wake that flows and one that beads.
//
// The pointer position the sim sees is a LEAD-COMPENSATED follower of the raw
// event position (POINTER_TAU), advanced in the rAF loop rather than in the
// event handler — so the path is continuous at frame rate no matter what
// cadence pointermove happens to fire at, and a 120Hz trackpad, a 60Hz mouse
// and a synthetic driver all produce the same motion.
//
// Lead-compensated, not plain, and that distinction is the whole point: a
// plain exponential follower has a steady-state error of exactly v*tau under
// constant velocity, and because the wake is laid down at the FOLLOWED
// position, that error is a lag the entire surface inherits. Smoothing the
// path that way buys continuity by making the metal late — which is a worse
// fault than the beading it was fixing, because the eye reads it as the
// surface not responding. Extrapolating the target one tau ahead cancels the
// term algebraically, so at constant velocity the head sits ON the cursor and
// the smoothing is spent only where it belongs: on direction changes and on
// interpolating between events that arrived sparser than frames.
//
// Samples are laid down by distance (SAMPLE_SPACING) with a time ceiling
// (SAMPLE_MAX_GAP), and each ring's amplitude is proportional to the interval
// of pointer travel it stands for, normalised to SAMPLE_REF_DT. That last part
// is what lets the sampling rate change freely — denser sampling means more,
// fainter rings, so the wake's integrated depth is a property of the pointer's
// motion and not of the sampling cadence.
//
// These numbers are set by a latency measurement, not by taste. tau is now the
// smoothing window only, not a lag, because the lead term above cancels the
// v*tau error; 12ms is about one frame of absorption at 60Hz. VEL_TAU has to
// outlive the gap between two pointer events or the velocity estimate — and
// with it the compensation — collapses to zero on every frame that happened to
// carry no event, which would put the lag back in as a flicker. LEAD_MAX caps
// the extrapolation so a teleporting pointer (a tab switch, a warp across the
// hero) cannot fling the head past the cursor. Likewise the spacing is a
// cadence: a
// sample is only deposited once the pointer has travelled SAMPLE_SPACING, so
// at 26px and 700px/s the head of the wake only advanced every ~2.2 frames —
// a 20Hz wake under a 60Hz page, which reads as lag even though nothing is
// dropping frames. 13px deposits roughly per frame. TRAIL is doubled in step
// so that SPACING*TRAIL — the length of the visible train — is unchanged at
// 260px; the extra slots cost ~0.3ms/frame at 2160x1350, measured.
//
// SAMPLE_MAX_GAP is the one that actually binds at ordinary speeds, and it is
// the reason the first pass at this only got halfway: 700px/s is 11.7px of
// travel per 60Hz frame, under SAMPLE_SPACING, so the deposit is made by the
// time ceiling rather than by the distance rule. At 33ms that ceiling is two
// frames — a 30Hz wake head under a 60Hz page. One frame's worth means the
// wake advances every frame at any speed a hand can produce, and the distance
// rule takes over above ~800px/s where it should.
const POINTER_TAU = 0.012;
const VEL_TAU = 0.06;
const LEAD_MAX = 24;
const SAMPLE_SPACING = 13;
const SAMPLE_MAX_GAP = 0.016;
const SAMPLE_REF_DT = 0.055;
const MAX_SUBSAMPLES = 5;

const FRAG_SRC = `
precision highp float;

#define TRAIL ${TRAIL}
#define LOBES ${LOBES}

uniform vec2 u_size;        // css px
uniform float u_dpr;
uniform float u_time;
uniform float u_scale;
uniform float u_relief;
uniform sampler2D u_text;   // R = blurred bevel, G = sharp glyph mask
uniform float u_textAmt;
uniform vec4 u_trail[TRAIL]; // x,y css px, z = age in seconds (negative = slot unused), w = amplitude
uniform vec3 u_lobes[LOBES]; // x,y css px, z = radius css px
uniform float u_hover;       // 0..1 eased
uniform float u_wake;        // 0 when every trail slot has decayed
uniform vec3 u_c0;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
uniform vec3 u_c4;
uniform vec3 u_accent;
uniform float u_bias;
uniform float u_contrast;

float hash21(vec2 p) {
  p = fract(p * vec2(287.13, 419.71));
  p += dot(p, p + 27.31);
  return fract(p.x * p.y);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm2(vec2 p) {
  float s = vnoise(p) * 0.62;
  s += vnoise(p * 2.07 + 11.3) * 0.31;
  return s;
}

float fbm3(vec2 p) {
  float s = vnoise(p) * 0.54;
  s += vnoise(p * 2.03 + 13.7) * 0.27;
  s += vnoise(p * 4.11 + 31.1) * 0.135;
  return s;
}

// Buoyant volumes rolling under the skin. Analytic, positions animated on the
// CPU, so this costs LOBES exp() calls and no noise.
float lobeField(vec2 p) {
  float s = 0.0;
  for (int i = 0; i < LOBES; i++) {
    vec2 d = (p - u_lobes[i].xy) / u_lobes[i].z;
    s += exp(-dot(d, d) * 1.1);
  }
  return s;
}

// Pointer wake: each sampled pointer position leaves a mexican-hat dent whose
// radius grows and whose amplitude decays, so a sweep drags a train of
// expanding rings through the pool instead of a single sticky blob.
//
// .w carries the interval of pointer travel the sample stands for, normalised
// to SAMPLE_REF_DT on the CPU. Summing amplitude-weighted rings makes the
// wake's depth track how the pointer moved rather than how often it was
// sampled, which is what lets the sampler run dense enough to look continuous.
float trailField(vec2 p) {
  // uniform-branch, so it is coherent across the whole draw: at rest — which is
  // the state the page spends most of its life in — the wake costs nothing
  if (u_wake <= 0.0) return 0.0;
  float s = 0.0;
  for (int i = 0; i < TRAIL; i++) {
    float age = u_trail[i].z;
    if (age < 0.0 || age > 1.9) continue;
    float rad = 30.0 + age * 165.0;
    vec2 d = (p - u_trail[i].xy) / rad;
    float r2 = dot(d, d);
    s += u_trail[i].w * (1.0 - r2 * 1.75) * exp(-r2 * 1.6) * exp(-age * 2.0);
  }
  return s;
}

vec2 flowWarp(vec2 q) {
  return vec2(
    fbm2(q + vec2(0.0, u_time * 0.055)),
    fbm2(q + vec2(3.71, -u_time * 0.043) + 2.13)
  );
}

// The single height field everything is differenced from.
float height(vec2 p, out float glyphSharp, out float glyphBlur) {
  float ref = min(u_size.x, u_size.y);
  vec2 q = p / (ref * 0.62) * u_scale;
  vec2 w = flowWarp(q);

  // headline first: its bevel gates everything else. Its lookup is displaced by
  // the same warp that drives the flow, so the letterforms shimmer like a
  // reflection on the pool rather than a decal pinned to it.
  vec2 uv = p / u_size + (w - 0.5) * 0.014;
  vec4 tex = texture2D(u_text, uv);
  glyphSharp = tex.g;
  glyphBlur = tex.r;
  // a glyph is a solid plate floating in a churning liquid, so the liquid's
  // own turbulence is damped to near-nothing across it. Without this gate the
  // pool's detail runs straight through the letters and the headline loses to
  // its own background at exactly the density that makes the pool worth having.
  float calm = 1.0 - 0.88 * tex.r;

  float base = fbm3(q * 1.18 + w * 1.05 + vec2(-u_time * 0.038, u_time * 0.021));
  float h = (base - 0.5) * 3.4 * mix(1.0, 0.35, tex.r);
  h += lobeField(p) * 2.2 * mix(1.0, 0.4, tex.r);

  // advected polish grain, deliberately ANISOTROPIC: sampled ~4x tighter across
  // the flow than along it, so the micro-structure reads as striations being
  // drawn out by a pour rather than as isotropic crumpled foil
  vec2 gq = (q + w * 0.55) * vec2(4.5, 17.0);
  h += (vnoise(gq + vec2(u_time * 0.22, -u_time * 0.6)) - 0.5) * 0.20 * calm;
  h += (vnoise(gq * 2.6 + vec2(-u_time * 0.4, u_time * 0.9)) - 0.5) * 0.07 * calm;

  h += tex.r * u_textAmt;

  h += trailField(p) * 1.9 * (0.4 + 0.6 * u_hover);
  return h;
}

float heightOnly(vec2 p) {
  float a; float b;
  return height(p, a, b);
}

// Analytic achromatic studio. sharp = 1 inside a glyph, where the surface is
// polished tighter: the strip light narrows and the banding gets finer, which
// is the entire reason inlaid type separates from the pool without a hue.
float strip(float el, float at, float width) {
  float d = (el - at) / width;
  return exp(-d * d);
}

float env(vec3 r, float sharp) {
  float el = r.y;
  float az = atan(r.x, r.z);
  // a five-source room. Density comes from the ENVIRONMENT having structure,
  // not from the height field having more noise: several narrow sources at
  // different elevations mean an almost-flat patch of surface still crosses
  // three or four reflection bands, which is what a real chrome sheet does.
  float L = 0.40;
  L += 0.26 * smoothstep(0.06, 0.68, el);
  L -= 0.24 * smoothstep(0.0, -0.58, el);
  L += 0.30 * strip(el, 0.13, mix(0.085, 0.038, sharp));
  L += 0.20 * strip(el, 0.42, mix(0.055, 0.026, sharp));
  L += 0.14 * strip(el, -0.28, 0.075);
  L += 0.10 * strip(el, -0.62, 0.05);
  // two vertical panels, slowly turning: the only source of horizontal
  // structure, and what keeps the frame from reading as pure horizon banding
  L += 0.11 * exp(-pow(sin(az * 1.0 + u_time * 0.09) / 0.42, 2.0));
  L += 0.07 * exp(-pow(sin(az * 2.0 - u_time * 0.06 + 1.1) / 0.30, 2.0));
  L += mix(0.028, 0.055, sharp) * sin(el * mix(14.0, 30.0, sharp) + az * 1.7);
  return L;
}

vec3 ramp(float x) {
  vec3 c = mix(u_c0, u_c1, smoothstep(0.0, 0.27, x));
  c = mix(c, u_c2, smoothstep(0.25, 0.55, x));
  c = mix(c, u_c3, smoothstep(0.52, 0.83, x));
  c = mix(c, u_c4, smoothstep(0.81, 1.0, x));
  return c;
}

void main() {
  // work in DOM-space px (y down) so pointer coords and the glyph texture
  // share one coordinate system with the field
  vec2 p = vec2(gl_FragCoord.x, u_size.y * u_dpr - gl_FragCoord.y) / u_dpr;
  float ref = min(u_size.x, u_size.y);

  float glyphSharp = 0.0;
  float glyphBlur = 0.0;
  float h0 = height(p, glyphSharp, glyphBlur);
  float eps = 1.35;
  float hx = heightOnly(p + vec2(eps, 0.0));
  float hy = heightOnly(p + vec2(0.0, eps));

  float k = u_relief / eps;
  vec3 n = normalize(vec3(-(hx - h0) * k, (hy - h0) * k, 1.0));

  vec2 vp = (p - u_size * 0.5) / ref;
  vec3 v = normalize(vec3(vp.x * 0.55, -vp.y * 0.55, 1.0));
  vec3 r = reflect(-v, n);

  float L = env(r, glyphSharp);

  // two drifting studio lights; the drift is slow enough to read as the room
  // turning rather than as a strobe
  vec3 l1 = normalize(vec3(-0.42 + 0.16 * sin(u_time * 0.17), 0.70, 0.56));
  vec3 l2 = normalize(vec3(0.62, 0.28 + 0.14 * sin(u_time * 0.13 + 1.9), 0.70));
  float s1 = pow(max(dot(r, l1), 0.0), mix(80.0, 240.0, glyphSharp));
  float s2 = pow(max(dot(r, l2), 0.0), mix(34.0, 110.0, glyphSharp));

  // brushed streaks: anisotropic micro-scratches stretched along x, sampled
  // once, modulating the environment rather than the albedo
  float brush = vnoise(vec2(p.x * 0.035, p.y * 1.85) + vec2(u_time * 0.05, 0.0));
  L += (brush - 0.5) * 0.055;

  float fres = pow(1.0 - max(dot(n, v), 0.0), 5.0);
  L += fres * 0.13;

  // contact shadow: the blurred bevel minus the sharp mask is exactly the
  // annulus of surface pulled up around each letter, and darkening it is what
  // makes the type sit IN the pool instead of hovering over it
  float skirt = max(glyphBlur - glyphSharp, 0.0);
  L -= skirt * 0.24;

  float Lc = clamp((L - 0.5) * u_contrast + 0.5 + u_bias, 0.0, 1.0);
  vec3 col = ramp(Lc);

  vec3 hot = mix(u_c4, u_accent, 0.13);
  col += hot * (s1 * (0.85 + 0.5 * glyphSharp) + s2 * 0.45);

  // mild vignette toward the deepest stop — full bleed, but the frame edges
  // stop competing with the headline
  float vig = smoothstep(0.58, 1.18, length(vp * vec2(1.0, 1.35)));
  col = mix(col, u_c0, vig * 0.32);

  gl_FragColor = vec4(col, 1.0);
}
`;

type RGB = [number, number, number];

function parseHex(raw: string): RGB | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

function mixRGB(a: RGB, b: RGB, t: number): RGB {
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}

function luminance([r, g, b]: RGB): number {
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
  const s = gl.createShader(type)!;
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
    const info = gl.getShaderInfoLog(s);
    gl.deleteShader(s);
    throw new Error(`weld-pool: shader compile failed: ${info ?? ""}`);
  }
  return s;
}

// ---------------------------------------------------------------------------
// GLSurface — the minimal full-bleed fragment-shader host: one program, one
// fullscreen triangle pair, uniform locations resolved lazily by name. It
// knows nothing about the pool, so a second preset can mount the same host
// with a different FRAG_SRC.
// ---------------------------------------------------------------------------
class GLSurface {
  gl: WebGLRenderingContext | null = null;
  private program: WebGLProgram | null = null;
  private vs: WebGLShader | null = null;
  private fs: WebGLShader | null = null;
  private buffer: WebGLBuffer | null = null;
  private locs = new Map<string, WebGLUniformLocation | null>();

  constructor(private canvas: HTMLCanvasElement, private frag: string) {}

  init(): boolean {
    const gl = this.canvas.getContext("webgl", {
      alpha: false,
      antialias: false,
      premultipliedAlpha: false,
      powerPreference: "high-performance",
    }) as WebGLRenderingContext | null;
    if (!gl) return false;
    this.gl = gl;
    try {
      this.vs = compile(gl, gl.VERTEX_SHADER, VERT_SRC);
      this.fs = compile(gl, gl.FRAGMENT_SHADER, this.frag);
      const program = gl.createProgram();
      if (!program) {
        this.destroy();
        return false;
      }
      this.program = program;
      gl.attachShader(program, this.vs);
      gl.attachShader(program, this.fs);
      gl.linkProgram(program);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        this.destroy();
        return false;
      }
    } catch {
      this.destroy();
      return false;
    }
    gl.useProgram(this.program);
    this.buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
      gl.STATIC_DRAW
    );
    const loc = gl.getAttribLocation(this.program!, "a_pos");
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
    this.locs.clear();
    return true;
  }

  private loc(name: string): WebGLUniformLocation | null {
    if (!this.locs.has(name)) {
      this.locs.set(name, this.gl!.getUniformLocation(this.program!, name));
    }
    return this.locs.get(name) ?? null;
  }

  f(name: string, x: number) {
    this.gl?.uniform1f(this.loc(name), x);
  }
  i(name: string, x: number) {
    this.gl?.uniform1i(this.loc(name), x);
  }
  v2(name: string, x: number, y: number) {
    this.gl?.uniform2f(this.loc(name), x, y);
  }
  v3(name: string, c: RGB | Float32Array) {
    this.gl?.uniform3f(this.loc(name), c[0], c[1], c[2]);
  }
  v3a(name: string, data: Float32Array) {
    this.gl?.uniform3fv(this.loc(name), data);
  }
  v4a(name: string, data: Float32Array) {
    this.gl?.uniform4fv(this.loc(name), data);
  }

  draw(pixelW: number, pixelH: number) {
    const gl = this.gl;
    if (!gl || !this.program) return;
    gl.viewport(0, 0, pixelW, pixelH);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }

  destroy() {
    const gl = this.gl;
    if (!gl) return;
    if (this.buffer) gl.deleteBuffer(this.buffer);
    if (this.program) gl.deleteProgram(this.program);
    if (this.vs) gl.deleteShader(this.vs);
    if (this.fs) gl.deleteShader(this.fs);
    this.buffer = null;
    this.program = null;
    this.vs = null;
    this.fs = null;
    this.locs.clear();
    this.gl = null;
  }
}

// Separable box blur over a single channel. Three passes approximate a
// gaussian closely enough for a bevel profile and cost a fraction of a real
// one; this runs once per resize/theme, never per frame.
function boxBlur(src: Uint8Array, w: number, h: number, radius: number, passes: number) {
  let a: Uint8Array<ArrayBufferLike> = src;
  let b: Uint8Array<ArrayBufferLike> = new Uint8Array(w * h);
  const r = Math.max(1, Math.round(radius));
  for (let pass = 0; pass < passes; pass++) {
    // horizontal
    for (let y = 0; y < h; y++) {
      const row = y * w;
      let sum = 0;
      for (let x = -r; x <= r; x++) sum += a[row + Math.min(w - 1, Math.max(0, x))];
      const norm = 2 * r + 1;
      for (let x = 0; x < w; x++) {
        b[row + x] = sum / norm;
        const out = row + Math.min(w - 1, Math.max(0, x - r));
        const inc = row + Math.min(w - 1, Math.max(0, x + r + 1));
        sum += a[inc] - a[out];
      }
    }
    [a, b] = [b, a];
    // vertical
    for (let x = 0; x < w; x++) {
      let sum = 0;
      for (let y = -r; y <= r; y++) sum += a[Math.min(h - 1, Math.max(0, y)) * w + x];
      const norm = 2 * r + 1;
      for (let y = 0; y < h; y++) {
        b[y * w + x] = sum / norm;
        const out = Math.min(h - 1, Math.max(0, y - r)) * w + x;
        const inc = Math.min(h - 1, Math.max(0, y + r + 1)) * w + x;
        sum += a[inc] - a[out];
      }
    }
    [a, b] = [b, a];
  }
  return a;
}

// A time offset chosen so the frame drawn under prefers-reduced-motion (and
// the resting frame the screenshot gate grades) already has the lobes spread
// across the field and a specular hit sitting on the headline, rather than
// whatever the flow happens to look like at t=0.
const STATIC_TIME = 6.4;

export function WeldPool({
  headline = "Molten",
  headlineWeight = 600,
  headlineFit = 0.82,
  headlineY = 0.44,
  relief = 1,
  speed = 1,
  scale = 1,
  paused = false,
  children,
  className = "",
  style,
}: WeldPoolProps) {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const uid = useId();

  const pausedRef = useRef(paused);
  pausedRef.current = paused;
  const headlineRef = useRef(headline);
  headlineRef.current = headline;

  useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;

    const surface = new GLSurface(canvas, FRAG_SRC);
    let raf = 0;
    let running = false;
    let staticMode = false;
    let cssW = 0;
    let cssH = 0;
    let dpr = 1;
    let disposed = false;
    let lastMs = performance.now();
    // Adaptive render scale, and it is insurance rather than the fix: measured
    // on an Apple M3 this shader costs 1.1ms per frame at 2880x1800, ~7% of a
    // 60Hz budget, so SCALES[0] is what every machine we can measure actually
    // runs at. The steps exist for the ones we cannot — a weak integrated GPU,
    // a 6K panel, a laptop throttling on battery. Frame cost is an EMA and a
    // step only happens after a sustained stretch over budget, so one GC pause
    // or a tab switch cannot cost quality.
    //
    // Every threshold here is in milliseconds of wall clock, never in frames.
    // A frame-counted gate is backwards: the slower the machine, the longer it
    // waits before helping — 90 frames is 1.5s at 60fps but 9s at 10fps, which
    // is most of the time the visitor was going to give it.
    //
    // Recovery matters as much as the step down, because the frame time this
    // watches is the PAGE's, not this component's: at 1.1ms of a 16.7ms budget
    // the pool is almost never the thing that blew it. A sibling animation, an
    // image decode or a layout storm would otherwise soften the metal for the
    // rest of the visit and turn a stutter complaint into a blur complaint. So
    // the surface climbs back the moment it is no longer missing budget, and
    // the wait before it tries again doubles on each failure — a transient
    // recovers in 8s, a genuinely slow machine stops probing within a minute.
    const SCALES = [1, 0.75, 0.55];
    const BUDGET_OVER = 24; // ms/frame that counts as missing the budget
    let scaleIdx = 0;
    let frameEma = 16.7;
    let overMs = 0;
    let underMs = 0;
    let upWindow = 8000;
    // integrated, per-frame-clamped clock rather than (now - startedAt): a long
    // frame — a GC pause, the tab coming back, a resize — then advances the
    // flow by one clamped step instead of teleporting it, and time simply stops
    // while the surface is asleep offscreen
    let simTime = 0;

    let hoverTarget = 0;
    let hoverAmt = 0;
    const trail = new Float32Array(TRAIL * 4).fill(-1);
    const trailNow = new Float32Array(TRAIL * 4);
    let trailHead = 0;
    const lobes = new Float32Array(LOBES * 3);

    // pointer: raw target from events, smoothed position advanced in the loop
    let havePointer = false;
    let tgtX = 0;
    let tgtY = 0;
    let ptrX = 0;
    let ptrY = 0;
    let velX = 0;
    let velY = 0;
    let lastTgtX = 0;
    let lastTgtY = 0;
    let sampleX = 0;
    let sampleY = 0;
    let lastSampleT = 0;
    // the wrap's viewport offset, cached: reading it per pointermove is a
    // forced layout on the hottest path there is
    let rectLeft = 0;
    let rectTop = 0;
    let rectDirty = true;

    let c0: RGB = [0.03, 0.03, 0.03];
    let c1: RGB = [0.18, 0.18, 0.18];
    let c2: RGB = [0.56, 0.56, 0.56];
    let c3: RGB = [0.93, 0.93, 0.93];
    let c4: RGB = [1, 1, 1];
    let accent: RGB = [0, 0.42, 1];
    let bias = 0;
    let contrast = 1.15;

    // Five stops spanning near-black to near-white in BOTH themes — a
    // full-bleed sheet is the page, so it does not need to invert the way a
    // thin band on a page does. Only the distribution moves: light theme sits
    // slightly brighter and slightly flatter (polished steel in a lit room),
    // dark theme deeper and harder.
    const readColors = () => {
      const cs = getComputedStyle(document.documentElement);
      const bg = parseHex(cs.getPropertyValue("--background")) ?? [1, 1, 1];
      const fg = parseHex(cs.getPropertyValue("--foreground")) ?? [0.09, 0.09, 0.09];
      const muted = parseHex(cs.getPropertyValue("--ns-muted")) ?? [0.55, 0.55, 0.55];
      const border = parseHex(cs.getPropertyValue("--border")) ?? [0.18, 0.18, 0.18];
      accent = parseHex(cs.getPropertyValue("--ns-accent")) ?? [0, 0.42, 1];
      const black: RGB = [0, 0, 0];
      const white: RGB = [1, 1, 1];
      if (luminance(bg) < 0.5) {
        c0 = mixRGB(bg, black, 0.55);
        c1 = mixRGB(border, bg, 0.15);
        c2 = muted;
        c3 = fg;
        c4 = mixRGB(fg, white, 0.85);
        bias = -0.09;
        contrast = 1.15;
      } else {
        c0 = mixRGB(fg, black, 0.35);
        c1 = mixRGB(fg, muted, 0.55);
        c2 = mixRGB(muted, bg, 0.6);
        c3 = mixRGB(bg, muted, 0.16);
        c4 = bg;
        bias = 0.02;
        contrast = 1.2;
      }
    };
    readColors();

    // ---- glyph texture --------------------------------------------------
    const texCanvas = document.createElement("canvas");
    let texture: WebGLTexture | null = null;
    let textAmt = 0;

    const rasterizeText = () => {
      const gl = surface.gl;
      if (!gl || cssW < 2 || cssH < 2) return;
      const aspect = cssH / cssW;
      // capped so the CPU blur stays trivial at any viewport width
      const tw = Math.max(256, Math.min(1024, Math.round(cssW)));
      const th = Math.max(128, Math.round(tw * aspect));
      texCanvas.width = tw;
      texCanvas.height = th;
      const ctx = texCanvas.getContext("2d", { willReadFrequently: true });
      if (!ctx) return;

      const family =
        getComputedStyle(wrap).fontFamily || "system-ui, sans-serif";
      const lines = headlineRef.current.split("\n").filter((l) => l.length > 0);
      ctx.clearRect(0, 0, tw, th);

      if (lines.length > 0) {
        // auto-fit: measure at a reference size, then scale so the longest
        // line lands on headlineFit of the width
        const probe = 100;
        ctx.font = `${headlineWeight} ${probe}px ${family}`;
        let widest = 1;
        for (const line of lines) widest = Math.max(widest, ctx.measureText(line).width);
        const size = Math.min((tw * headlineFit * probe) / widest, (th * 0.72) / lines.length);
        ctx.font = `${headlineWeight} ${size}px ${family}`;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillStyle = "#fff";
        const lead = size * 0.98;
        const top = th * headlineY - ((lines.length - 1) * lead) / 2;
        for (let i = 0; i < lines.length; i++) {
          ctx.fillText(lines[i], tw / 2, top + i * lead);
        }
      }

      const img = ctx.getImageData(0, 0, tw, th).data;
      const sharp = new Uint8Array(tw * th);
      for (let i = 0, j = 3; i < sharp.length; i++, j += 4) sharp[i] = img[j];
      const blurred = boxBlur(Uint8Array.from(sharp), tw, th, Math.max(2, tw * 0.0045), 3);

      const rgba = new Uint8Array(tw * th * 4);
      for (let i = 0, j = 0; i < sharp.length; i++, j += 4) {
        // R carries the bevel (blurred, so its gradient is the letter's slope),
        // G the hard mask (roughness switch + contact-shadow inner edge)
        rgba[j] = Math.max(blurred[i], sharp[i]);
        rgba[j + 1] = sharp[i];
        rgba[j + 2] = 0;
        rgba[j + 3] = 255;
      }

      if (!texture) {
        texture = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_2D, texture);
        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);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      } else {
        gl.bindTexture(gl.TEXTURE_2D, texture);
      }
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, tw, th, 0, gl.RGBA, gl.UNSIGNED_BYTE, rgba);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, texture);
      textAmt = lines.length > 0 ? 1 : 0;
    };

    // ---- frame ----------------------------------------------------------
    const updateLobes = (t: number) => {
      const ref = Math.min(cssW, cssH);
      for (let i = 0; i < LOBES; i++) {
        const ph = i * 1.7;
        const sp = 0.09 + i * 0.031;
        lobes[i * 3] =
          cssW * (0.5 + 0.34 * Math.sin(t * sp + ph) * Math.cos(t * sp * 0.61 + ph * 1.3));
        lobes[i * 3 + 1] =
          cssH * (0.5 + 0.36 * Math.sin(t * sp * 0.83 + ph * 2.1));
        lobes[i * 3 + 2] = ref * (0.22 + 0.09 * Math.sin(t * 0.21 + ph));
      }
    };

    const draw = () => {
      if (!surface.gl || cssW <= 0 || cssH <= 0) return;
      const t = staticMode ? STATIC_TIME : simTime;
      updateLobes(t);
      // ages are refreshed here rather than at push time so a paused/static
      // frame does not freeze a half-decayed wake mid-flight
      trailNow.set(trail);
      let wakeAlive = false;
      for (let i = 0; i < TRAIL; i++) {
        const born = trail[i * 4 + 2];
        const age = born < 0 ? -1 : t - born;
        trailNow[i * 4 + 2] = age;
        if (age >= 0 && age <= 1.9) wakeAlive = true;
      }
      surface.v2("u_size", cssW, cssH);
      surface.f("u_dpr", dpr);
      surface.f("u_time", t);
      surface.f("u_scale", Math.max(0.2, scale));
      surface.f("u_relief", 15 * Math.max(0, relief));
      surface.f("u_textAmt", textAmt * 0.5);
      surface.i("u_text", 0);
      surface.v4a("u_trail", trailNow);
      surface.v3a("u_lobes", lobes);
      surface.f("u_hover", hoverAmt);
      surface.f("u_wake", wakeAlive ? 1 : 0);
      surface.v3("u_c0", c0);
      surface.v3("u_c1", c1);
      surface.v3("u_c2", c2);
      surface.v3("u_c3", c3);
      surface.v3("u_c4", c4);
      surface.v3("u_accent", accent);
      surface.f("u_bias", bias);
      surface.f("u_contrast", contrast);
      surface.draw(canvas.width, canvas.height);
    };

    const loop = (nowMs: number) => {
      const rawMs = nowMs - lastMs;
      const dt = Math.min(0.05, Math.max(0, rawMs / 1000));
      lastMs = nowMs;
      simTime += dt * speed;
      hoverAmt += (hoverTarget - hoverAmt) * (1 - Math.exp(-dt * 8));
      stepPointer(dt);
      draw();
      // clamped the same way the clock is, so a tab returning from the
      // background cannot inject a one-second frame into the average, and
      // time-constant rather than frame-count smoothing so the average settles
      // in ~120ms of wall clock whatever the frame rate is
      const clamped = Math.min(50, rawMs);
      frameEma += (clamped - frameEma) * (1 - Math.exp(-clamped / 120));
      if (frameEma > BUDGET_OVER) {
        overMs += clamped;
        underMs = 0;
      } else {
        underMs += clamped;
        overMs = 0;
      }
      // asymmetric on purpose: drop after ~0.9s of stutter, climb back only
      // after a much longer clean stretch, so a marginal surface cannot
      // oscillate between two resolutions
      const down = overMs > 900 && scaleIdx < SCALES.length - 1;
      const up = underMs > upWindow && scaleIdx > 0;
      if (down || up) {
        scaleIdx += down ? 1 : -1;
        if (down) upWindow = Math.min(64000, upWindow * 2);
        overMs = 0;
        underMs = 0;
        frameEma = 16.7;
        applyBacking();
      }
      raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (running || disposed) return;
      running = true;
      lastMs = performance.now();
      raf = requestAnimationFrame(loop);
    };
    const sleep = () => {
      cancelAnimationFrame(raf);
      running = false;
    };

    // DPR is capped at 1.5 rather than the usual 2: this shader is full-bleed
    // and its per-pixel cost is three evaluations of a warped fbm field, so
    // the area term dominates. 1.5 keeps a 1440x900 hero comfortably at frame
    // rate without a visible loss of specular detail. Resizing the backing
    // store is separated from the layout read so the adaptive step can change
    // resolution without re-rasterizing the headline or forcing a layout.
    const applyBacking = () => {
      if (cssW < 2 || cssH < 2) return;
      dpr = Math.min(window.devicePixelRatio || 1, 1.5) * SCALES[scaleIdx];
      const pw = Math.round(cssW * dpr);
      const ph = Math.round(cssH * dpr);
      // assigning width/height clears the drawing buffer even when the value
      // is unchanged, so only touch it on a real change
      if (canvas.width !== pw || canvas.height !== ph) {
        canvas.width = pw;
        canvas.height = ph;
      }
      canvas.style.width = `${cssW}px`;
      canvas.style.height = `${cssH}px`;
      draw();
    };

    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) return;
      const changed = Math.abs(rect.width - cssW) > 0.5 || Math.abs(rect.height - cssH) > 0.5;
      cssW = rect.width;
      cssH = rect.height;
      rectLeft = rect.left;
      rectTop = rect.top;
      rectDirty = false;
      // a new size is a new cost, so the adaptive ladder starts over rather
      // than carrying a verdict earned at a different number of fragments
      scaleIdx = 0;
      overMs = 0;
      underMs = 0;
      upWindow = 8000;
      frameEma = 16.7;
      applyBacking();
      if (changed) rasterizeText();
      draw();
    };

    const syncRect = () => {
      if (!rectDirty) return;
      const rect = wrap.getBoundingClientRect();
      rectLeft = rect.left;
      rectTop = rect.top;
      rectDirty = false;
    };
    const markRectDirty = () => {
      rectDirty = true;
    };

    const pushTrail = (x: number, y: number, born: number, amp: number) => {
      const i = trailHead * 4;
      trail[i] = x;
      trail[i + 1] = y;
      trail[i + 2] = born;
      trail[i + 3] = amp;
      trailHead = (trailHead + 1) % TRAIL;
    };

    // Advance the smoothed pointer one frame and lay down whatever wake samples
    // that step of travel earned. Everything the sim sees about the pointer is
    // produced here, in the frame, from a target the event handlers only ever
    // assign to — so event cadence, coalescing and burstiness cannot reach the
    // surface, and a fast flick lays an evenly spaced train instead of two
    // beads at wherever the two events happened to land.
    const stepPointer = (dt: number) => {
      if (!havePointer || dt <= 0) return;
      // velocity of the raw target, smoothed over a window longer than the gap
      // between two events so it survives a frame that carried none
      const vk = 1 - Math.exp(-dt / VEL_TAU);
      velX += ((tgtX - lastTgtX) / dt - velX) * vk;
      velY += ((tgtY - lastTgtY) / dt - velY) * vk;
      lastTgtX = tgtX;
      lastTgtY = tgtY;

      let leadX = velX * POINTER_TAU;
      let leadY = velY * POINTER_TAU;
      const lead = Math.hypot(leadX, leadY);
      if (lead > LEAD_MAX) {
        leadX = (leadX / lead) * LEAD_MAX;
        leadY = (leadY / lead) * LEAD_MAX;
      }
      const k = 1 - Math.exp(-dt / POINTER_TAU);
      ptrX += (tgtX + leadX - ptrX) * k;
      ptrY += (tgtY + leadY - ptrY) * k;

      const dx = ptrX - sampleX;
      const dy = ptrY - sampleY;
      const dist = Math.hypot(dx, dy);
      const gap = simTime - lastSampleT;
      if (dist < SAMPLE_SPACING && !(gap >= SAMPLE_MAX_GAP && dist > 1.5)) return;

      const n = Math.min(MAX_SUBSAMPLES, Math.max(1, Math.round(dist / SAMPLE_SPACING)));
      // amplitude is the share of the travel interval each sample stands for,
      // so N fainter rings deposit exactly what one ring at the old fixed
      // cadence would have: sampling density becomes a smoothness knob rather
      // than a depth knob
      const amp = Math.min(1.6, gap / SAMPLE_REF_DT) / n;
      for (let s = 1; s <= n; s++) {
        const f = s / n;
        pushTrail(sampleX + dx * f, sampleY + dy * f, lastSampleT + gap * f, amp);
      }
      sampleX = ptrX;
      sampleY = ptrY;
      lastSampleT = simTime;
    };

    // Static mode has no loop to smooth in, and its clock is frozen, so the
    // wake collapses to a single ring under the pointer rather than a train.
    const staticPoint = () => {
      trail.fill(-1);
      trailHead = 0;
      pushTrail(ptrX, ptrY, STATIC_TIME, 1);
      draw();
    };

    const setTarget = (e: PointerEvent) => {
      syncRect();
      // the last coalesced point is the pointer's true current position; the
      // event's own coordinates can be a frame stale on a high-rate device
      const co =
        typeof e.getCoalescedEvents === "function" ? e.getCoalescedEvents() : null;
      const last = co && co.length > 0 ? co[co.length - 1] : e;
      tgtX = last.clientX - rectLeft;
      tgtY = last.clientY - rectTop;
    };

    // entering, pressing, or coming back after a gap teleports the smoothed
    // position instead of easing to it — otherwise re-entry drags a wake across
    // everything between where the pointer left and where it came back
    const snapPointer = () => {
      ptrX = tgtX;
      ptrY = tgtY;
      // a teleport carries no velocity, and a stale estimate would extrapolate
      // the head off along whatever direction the pointer had before it left
      velX = 0;
      velY = 0;
      lastTgtX = tgtX;
      lastTgtY = tgtY;
      sampleX = tgtX;
      sampleY = tgtY;
      lastSampleT = simTime;
      havePointer = true;
    };

    const onPointerEnter = (e: PointerEvent) => {
      hoverTarget = 1;
      setTarget(e);
      snapPointer();
      if (staticMode) {
        staticPoint();
        return;
      }
      pushTrail(tgtX, tgtY, simTime, 1);
    };
    const onPointerLeave = () => {
      hoverTarget = 0;
      havePointer = false;
    };
    const onPointerMove = (e: PointerEvent) => {
      setTarget(e);
      if (!havePointer) {
        // no enter fired: the surface appeared under a resting pointer, or a
        // touch was lifted and put back down
        snapPointer();
        hoverTarget = 1;
      }
      if (staticMode) {
        ptrX = tgtX;
        ptrY = tgtY;
        staticPoint();
      }
    };
    const onPointerDown = (e: PointerEvent) => {
      // a press drops a fresh, full-amplitude ring at the contact point
      setTarget(e);
      snapPointer();
      hoverTarget = 1;
      if (staticMode) {
        staticPoint();
        return;
      }
      pushTrail(tgtX, tgtY, simTime, 1);
    };
    const onPointerUp = (e: PointerEvent) => {
      // a lifted touch or pen has no position any more, and no pointerleave is
      // coming: without this the surface stays hovered forever after one tap
      if (e.pointerType !== "mouse") {
        hoverTarget = 0;
        havePointer = false;
      }
    };
    const onPointerCancel = () => {
      hoverTarget = 0;
      havePointer = false;
    };

    if (!surface.init()) return; // no WebGL: children still render over the page bg
    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    resize();
    rasterizeText();
    // webfont metrics are not final at mount; re-rasterizing after fonts.ready
    // is what stops fallback letterforms from being baked into the texture
    if (document.fonts?.ready) {
      document.fonts.ready.then(() => {
        if (disposed) return;
        rasterizeText();
        if (staticMode) draw();
      });
    }

    wrap.addEventListener("pointerenter", onPointerEnter);
    wrap.addEventListener("pointerleave", onPointerLeave);
    wrap.addEventListener("pointermove", onPointerMove);
    wrap.addEventListener("pointerdown", onPointerDown);
    wrap.addEventListener("pointerup", onPointerUp);
    wrap.addEventListener("pointercancel", onPointerCancel);
    // the wrap's viewport offset only moves on scroll or layout, so mark it
    // stale here and re-read it once, on the next pointer event, instead of
    // forcing a layout inside every pointermove
    window.addEventListener("scroll", markRectDirty, { passive: true, capture: true });
    window.addEventListener("resize", markRectDirty, { passive: true });

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;
    const applyMode = () => {
      if (reduced || pausedRef.current) {
        staticMode = true;
        sleep();
        draw();
      } else {
        staticMode = false;
        wake();
      }
    };
    const onMq = () => {
      reduced = mq.matches;
      applyMode();
    };
    mq.addEventListener("change", onMq);

    // pause when scrolled out of view — a full-bleed shader off-screen is the
    // most expensive idle thing a page can carry
    let onScreen = true;
    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries.some((en) => en.isIntersecting);
        if (!onScreen) sleep();
        else if (!staticMode && !document.hidden) wake();
      },
      { threshold: 0 }
    );
    io.observe(wrap);

    const onVis = () => {
      if (document.hidden) sleep();
      else if (!staticMode && onScreen) wake();
    };
    document.addEventListener("visibilitychange", onVis);
    applyMode();

    let lastPolledPaused = pausedRef.current;
    let lastPolledHeadline = headlineRef.current;
    // polled instead of made effect dependencies: either would tear down and
    // recreate the whole GL context to change a string or a boolean
    let poll = 0;
    const tick = () => {
      if (pausedRef.current !== lastPolledPaused) {
        lastPolledPaused = pausedRef.current;
        applyMode();
      }
      if (headlineRef.current !== lastPolledHeadline) {
        lastPolledHeadline = headlineRef.current;
        rasterizeText();
        if (staticMode) draw();
      }
      poll = window.setTimeout(tick, 140);
    };
    tick();

    const themeObserver = new MutationObserver(() => {
      readColors();
      if (staticMode) draw();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    const onLost = (e: Event) => {
      e.preventDefault();
      sleep();
    };
    const onRestored = () => {
      texture = null;
      if (surface.init()) {
        resize();
        rasterizeText();
        applyMode();
      }
    };
    canvas.addEventListener("webglcontextlost", onLost);
    canvas.addEventListener("webglcontextrestored", onRestored);

    return () => {
      disposed = true;
      ro.disconnect();
      io.disconnect();
      mq.removeEventListener("change", onMq);
      document.removeEventListener("visibilitychange", onVis);
      themeObserver.disconnect();
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      wrap.removeEventListener("pointerenter", onPointerEnter);
      wrap.removeEventListener("pointerleave", onPointerLeave);
      wrap.removeEventListener("pointermove", onPointerMove);
      wrap.removeEventListener("pointerdown", onPointerDown);
      wrap.removeEventListener("pointerup", onPointerUp);
      wrap.removeEventListener("pointercancel", onPointerCancel);
      window.removeEventListener("scroll", markRectDirty, { capture: true } as EventListenerOptions);
      window.removeEventListener("resize", markRectDirty);
      window.clearTimeout(poll);
      sleep();
      if (texture && surface.gl) surface.gl.deleteTexture(texture);
      texture = null;
      surface.destroy();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [headlineWeight, headlineFit, headlineY, relief, speed, scale]);

  return (
    <div
      ref={wrapRef}
      data-weld-pool={uid}
      className={`relative isolate h-full w-full touch-none overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block" />
      {/* the visible headline lives in the metal, so the accessible one lives
          here — same string, no second visual copy to keep in sync */}
      <h1 className="sr-only">{headline.split("\n").join(" ")}</h1>
      {children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
    </div>
  );
}

WeldPool.displayName = "WeldPool";
Build spec

Build <WeldPool headline? headlineWeight? headlineFit? headlineY? relief? speed? scale? paused? children? className?> as a full-bleed WebGL hero rendering a molten metal pool with real headline type lying IN the surface. ONE HEIGHT FIELD: the whole component is a single fragment shader that evaluates h(x,y,t) and central-differences it (three taps, eps 1.35px, relief 15) for one surface normal. h is the sum of four contributions, and the point of summing them is that the type is then indistinguishable from the metal — it warps, catches light and dents with the flow instead of floating over it. (1) Convecting bulk: a domain-warped fbm, w = vec2(fbm2(q + t*0.055), fbm2(q + 3.71 - t*0.043 + 2.13)) and base = fbm3(q*1.18 + w*1.05 + drift), contributing (base - 0.5)*3.4, with q = p/(min(W,H)*0.62)*scale. (2) Four gaussian lobes at 2.2 amplitude, positions animated on the CPU on lissajous paths and passed as a vec3[4] uniform (x, y, radius) — buoyant volumes rolling under the skin, analytic so they cost four exp() calls and no noise. (3) An ANISOTROPIC advected grain: two octaves sampled on gq = (q + w*0.55)*vec2(4.5, 17.0), i.e. ~4x tighter across the flow than along it, at 0.20 and 0.07 amplitude. Isotropic grain at this amplitude reads as crumpled foil; stretching it turns the same energy into striations being drawn out by a pour. The magnitudes matter more than they look: an early build had the pool's slopes an order of magnitude gentler than the glyph bevel's, and the whole field collapsed into an out-of-focus grey cloud while the letters alone read as chrome. (4) The glyph bevel, sampled at uv = p/size + (w - 0.5)*0.014 — displacing the lookup by the same warp that drives the flow is what makes the letterforms shimmer like a reflection rather than a decal pinned to the pool. THE CALM GATE, which is the single most load-bearing line: a glyph is a solid plate floating in a churning liquid, so the liquid's turbulence is damped across it — the bulk scales by mix(1.0, 0.35, bevel), the lobes by mix(1.0, 0.4, bevel), and the grain by (1 - 0.88*bevel). Without it the pool's detail runs straight through the letters and the headline loses to its own background at exactly the density that makes the pool worth having. GLYPH TEXTURE: the headline is rasterized on a 2D canvas at up to 1024px wide using the wrapper's own computed font-family, auto-fit so the longest line lands on headlineFit of the width; the alpha channel is copied out, box-blurred separably three times at radius ~0.45% of the texture width, and packed as R = max(blurred, sharp) (a plateau with a soft skirt, whose gradient IS the letter's slope) and G = the hard mask. Rasterization repeats after document.fonts.ready — a texture is baked once, so a pre-webfont raster would freeze fallback letterforms permanently — and on any headline change, without recreating the GL context. MONOCHROME METAL: metal normally reads through hue; here the palette is achromatic, so every cue is carried by value, and density comes from the ENVIRONMENT having structure rather than the height field having more noise. r = reflect(-v, n) is looked up in an analytic achromatic studio of five sources at different elevations — a broad sky smoothstep(0.06, 0.68, r.y), a dark floor, and gaussian strips at el 0.13, 0.42, -0.28 and -0.62 — plus two slowly turning azimuthal panels (the only source of horizontal structure, without which the frame reads as pure horizon banding), a polish banding term, two drifting sharp specular lobes (exponents 80 and 34), anisotropic brushed streaks from one noise sample stretched ~50:1 along x, and a fresnel rim at pow(1 - dot(n,v), 5). Several narrow sources mean an almost-flat patch of surface still crosses three or four reflection bands, which is what a real chrome sheet does. G, the hard glyph mask, drives a roughness switch: inside a letter the main strip narrows from sigma 0.085 to 0.038, the polish banding roughly doubles in frequency and the specular exponents rise to 240/110, so inlaid type separates from the pool by being POLISHED TIGHTER, with no colour difference at all. A contact shadow of max(blur - sharp, 0)*0.24 darkens the annulus of surface pulled up around each letter. PALETTE: five stops from --background, --foreground, --ns-muted and --border read via getComputedStyle and re-read on a documentElement class MutationObserver, plus --ns-accent tinting only the hottest specular at 13%. Unlike a thin metal band on a page, a full-bleed sheet IS the page, so the ramp spans near-black to near-white in BOTH themes and the direction never inverts — only the distribution moves (light theme bias +0.02, contrast 1.20, reading as polished steel in a lit room; dark theme bias -0.09, contrast 1.15, the same pool sunk). A mild elliptical vignette toward the deepest stop stops the frame edges competing with the headline. POINTER: pointer positions are pushed into a 6-slot ring buffer at ~18Hz; each becomes a mexican-hat dent, (1 - 1.75r^2)*exp(-1.6r^2), whose radius grows 30 -> ~350px and whose amplitude decays over 1.9s, so a sweep drags a train of expanding rings through the pool and a press drops one full-amplitude ring at the contact point. Pointer-enter alone eases a hover term from 0.4 to 1.0 on the wake amplitude, so hovering differs from rest even with no motion. A u_wake uniform gates the trail loop entirely — a coherent uniform branch, so at rest, which is where the page spends most of its life, the wake costs nothing. HOST: an internal GLSurface class owns the program, the fullscreen triangle pair and lazily-resolved uniform locations and knows nothing about the pool, so a second preset can mount the same host with a different fragment source. DPR is capped at 1.5 rather than 2 because the shader is full-bleed and its cost is three field evaluations per pixel, so the area term dominates. ResizeObserver re-sizes and re-rasterizes; an IntersectionObserver and visibilitychange both stop the single rAF loop; webglcontextlost/restored rebuild cleanly. prefers-reduced-motion (and paused) draw exactly one composed still frame at t=6.4 — lobes spread across the field, striations drawn out and a specular hit on the headline, never a blank grey box — and pointer input still redraws that frozen frame. The accessible copy of the headline is an sr-only <h1>, since the visible one exists only as a height field; children render over the pool for an eyebrow, subhead and CTA, and because the surface spans the full value range they need a token scrim (bg-background/70 + backdrop-blur) rather than bare type.

Props

PropTypeDefaultDescription
headline?string"Molten"Headline rasterized into the metal. "\n" splits lines.
headlineWeight?number600Glyph weight for the rasterized headline. @default 600
headlineFit?number0.82Fraction of the container width the longest headline line fills. @default 0.82
headlineY?number0.44Vertical centre of the headline block, 0 = top, 1 = bottom. @default 0.44
relief?number1How far the glyphs stand out of the pool, 0..1. @default 1
speed?number1Flow speed multiplier. @default 1
scale?number1Feature size of the convecting bulk — larger reads as a tighter boil. @default 1
paused?booleanfalseFreezes the surface on a composed still frame without unmounting.
children?React.ReactNodeRendered in the DOM over the pool — eyebrow, subhead, CTA.
className?string
style?React.CSSProperties