IrisBackgroundsCobalt Foundry
Cobalt Foundry
The Foundry pour in electric blue: a cobalt stream whose splash burns through cyan to white, over a blue-black ground.
Cobalt Foundry
The foundry pour, recoloured through its colors prop. Same physics, same measured geometry, same freeze front; only the five colours change. Here the glow is cobalt, so the hot pool runs through cyan to white and the cooling film fades back to deep blue before it freezes.
It's the same component as the molten base, so the copy below is FoundryField with these colours passed in. Change them to anything: the rim's whole cooling ramp is built from glow, and the stream's from stream and core. FOUNDRY_PALETTES.cobalt holds this set too.
Install
No installation needed — self-contained, paste-in code.
Usage
Drop it straight into a page.
import { FoundryField } from "./FoundryField";
export default function Example() {
return (
// Fills its nearest positioned ancestor (it renders itself `absolute
// inset-0`) — give it a sized, relatively positioned box.
<div className="relative isolate h-[32rem] w-full overflow-hidden rounded-2xl">
<FoundryField
colors={{
glow: "#1f6bff",
stream: "#2a6cff",
core: "#b8c4ff",
ground: "#010309",
panel: "#0d0d0f",
}}
/>
</div>
);
}Component
The real source, exactly as it ships — multiple files, kept together.
"use client";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import { mountShaderSurface } from "@/lib/shader-surface";
/**
* Molten light poured onto a surface. A single stream falls from above the
* frame onto the top edge of a dark panel, bursts white where it lands,
* spreads both ways along that edge, and wherever it still has heat left
* when it reaches a corner, runs over and on down the panel's side.
*
* Built to one reference and measured off it, pixel by pixel: the stream's
* pink-white core inside an orange-red body, the white splash, the long
* gold run along the top edge that drops off sharply to a dull brown, and a
* hot near flank against a barely-lit far one. Each of those falls out of
* the physics below rather than being painted on:
*
* • The stream falls under gravity. Brightness pulses in the pour are
* carried down it at the liquid's own speed, so they visibly accelerate
* and stretch on the way down.
* • On the top edge the sheet leaves the impact fast and slows with
* friction, so it takes longer and longer to cover each bit of edge. It
* cools with that time (white → gold → orange → red), but it also piles
* up thicker as it slows, which holds the glow nearly level — the long
* gold band — until it gets cold enough to freeze. That freeze front is
* the sharp drop-off in the reference.
* • Over a corner, gravity takes it again. It speeds up down the flank,
* which spreads the film thinner but keeps it hot for longer, so the near
* side glows all the way down while the far side, which the sheet never
* reaches before freezing, only catches ambient light.
* • Splash droplets fly ballistic arcs off the impact. If the panel's
* bottom is in frame, drips leave the lower corners and fall.
*
* The pour stays where the reference puts it; the pointer doesn't move it.
*
* Colours come in through `colors`, as a few hex keys rather than the two
* full ramps. The rim's whole ramp is built from `glow` alone: below it the
* glow dimmed, above it the glow overexposed toward white, which is how the
* measured orange runs to gold, then yellow, then white (and how a blue runs
* through cyan to white). The stream's ramp is built from `stream` and the
* `core` tint it runs to before white. With no `colors`, or the molten keys
* themselves, the field uses the ramps measured off the reference exactly.
* `FOUNDRY_PALETTES` holds the molten base and four recolours.
*
* By default the surface is a panel laid out as in the reference. Pass
* `surfaceSelector` to pour onto a real element instead, such as your
* product screenshot. The field measures that element every frame and lights
* its rim, reading its corner radius off its border-radius.
*
* One of the reusable background fields. Drop it into any `position:
* relative`/`isolate` parent — it fills the box. Built on
* `lib/shader-surface.ts`, so every degradation path is already handled: no
* WebGL, a blocked or lost context, a hidden tab, or `prefers-reduced-motion`
* all leave the CSS floor underneath visible.
*/
type RGB = [number, number, number];
type Hex = `#${string}`;
/** The colours a `FoundryField` is built from — hex, `#rgb` or `#rrggbb`. */
export interface FoundryColors {
/** The light on the rim and in the splash. Pick it bright and saturated:
* the field dims it for the cooling film and pales it toward white for
* the hot pool itself. */
glow: Hex;
/** The falling stream's body. */
stream: Hex;
/** The tint the stream's core runs to before it goes white. */
core: Hex;
/** The ground behind everything. */
ground: Hex;
/** The panel's face. */
panel: Hex;
}
/* The base, measured off the reference, and four recolours of it. */
export const FOUNDRY_PALETTES = {
molten: { glow: "#fe5c16", stream: "#ff5d21", core: "#ffa6ac", ground: "#020106", panel: "#0d0d0e" },
cobalt: { glow: "#1f6bff", stream: "#2a6cff", core: "#b8c4ff", ground: "#010309", panel: "#0d0d0f" },
verdigris: { glow: "#19e6a0", stream: "#16d494", core: "#b8ffe9", ground: "#010504", panel: "#0c0e0d" },
magenta: { glow: "#ff2d8a", stream: "#ff2f7d", core: "#ffb3e6", ground: "#060107", panel: "#0e0d0e" },
violet: { glow: "#8a4dff", stream: "#7f4bff", core: "#dcc4ff", ground: "#030109", panel: "#0d0d0f" },
} satisfies Record<string, FoundryColors>;
const MOLTEN = FOUNDRY_PALETTES.molten;
const hex = (h: string): RGB => {
const s = h.replace("#", "");
const full = s.length === 3 ? s.replace(/./g, "$&$&") : s;
const n = parseInt(full.slice(0, 6), 16);
return Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
};
const rgb255 = (...c: number[][]): RGB[] => c.map((v) => v.map((x) => x / 255) as RGB);
const mixRGB = (a: RGB, b: RGB, t: number): RGB => a.map((x, i) => x + (b[i] - x) * t) as RGB;
const WHITE: RGB = [1, 1, 1];
/* The two ramps measured off the reference, stop for stop (energies in the
shader). These are what the base draws with. */
const MOLTEN_RIM = rgb255(
[21, 8, 5], [44, 14, 8], [81, 24, 11], [130, 38, 15], [205, 60, 22], [240, 67, 15], [254, 92, 22],
[255, 125, 35], [255, 139, 54], [255, 167, 56], [255, 185, 62], [255, 231, 98], [255, 250, 168], [255, 248, 245],
);
const MOLTEN_BEAM = rgb255(
[55, 18, 9], [100, 28, 8], [196, 54, 14], [255, 93, 33], [255, 131, 94],
[255, 166, 172], [255, 220, 215], [255, 238, 233], [255, 250, 248],
);
/* Any other glow gets the same shape, fitted to the measured ramp (within
20/255 on its worst channel): below the glow, the glow dimmed; above it,
the glow overexposed — 1 − (1 − c)^k — so each channel saturates in turn
and the colour pales toward white the way a hot light does. */
const RIM_DIM = [0.083, 0.173, 0.319, 0.51, 0.81, 0.945, 1];
const RIM_EXPOSE = [1.55, 2, 2.5, 3, 5.3, 10, 30];
const rimFrom = (g: RGB): RGB[] => [
...RIM_DIM.map((k) => g.map((c) => c * k) as RGB),
...RIM_EXPOSE.map((k) => g.map((c) => 1 - (1 - c) ** k) as RGB),
];
/* The stream: its body dimmed below, then running to the core tint, then
to white. */
const beamFrom = (body: RGB, core: RGB): RGB[] => [
...[0.216, 0.392, 0.769, 1].map((k) => body.map((c) => c * k) as RGB),
mixRGB(body, core, 0.48),
core,
...[0.56, 0.77, 0.93].map((t) => mixRGB(core, WHITE, t)),
];
interface Palette {
ground: RGB;
panel: RGB;
rim: RGB[];
beam: RGB[];
}
function buildPalette(colors?: Partial<FoundryColors>): Palette {
const c = { ...MOLTEN, ...colors };
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
const beamIsMolten = same(c.stream, MOLTEN.stream) && same(c.core, MOLTEN.core);
return {
ground: hex(c.ground),
panel: hex(c.panel),
rim: same(c.glow, MOLTEN.glow) ? MOLTEN_RIM : rimFrom(hex(c.glow)),
beam: beamIsMolten ? MOLTEN_BEAM : beamFrom(hex(c.stream), hex(c.core)),
};
}
const BORDER: RGB = [0.12, 0.115, 0.115]; // the panel's hairline border, lit by the rim
const css = ([r, g, b]: RGB, a = 1) =>
`rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)}${a < 1 ? ` / ${a}` : ""})`;
/* Physics, in frame heights (H) and seconds. Spliced into the shader and
used on the CPU as well, so the stream's fall time and the sparks'
gravity can never disagree. */
const G = 1.4; // gravity, H/s²
const SRC_Y = 1.25; // the pour's source, a quarter-frame above the top edge
const V_IN = 0.45; // stream speed leaving the source, H/s
/* Surges leave the source every SURGE seconds, at the middle of each
period. The still frame (reduced motion) sits just before one leaves, when
the last has had time to freeze: a calm stream on a settled rim, which is
the reference. */
const SURGE = 3.1;
const STILL = SURGE * 4.5 - 0.05;
/** Seconds for the stream to fall from its source to height `y` (uv, y up). */
const fallTime = (y: number) =>
(Math.sqrt(V_IN * V_IN + 2 * G * Math.max(SRC_Y - y, 0)) - V_IN) / G;
const f = (n: number) => n.toFixed(4);
const FRAG = `
uniform vec2 u_res;
uniform float u_time;
uniform float u_scale;
uniform vec3 u_ground;
uniform vec3 u_panel;
uniform vec3 u_border;
uniform vec3 u_rim[14]; /* the rim's ramp, one colour per stop below */
uniform vec3 u_beam[9]; /* the stream's */
uniform vec4 u_box; /* the surface: x0, y0, x1, y1 in uv, y up */
uniform float u_radius; /* its corner radius, in frame heights */
uniform float u_pour; /* where the stream falls and lands, uv x */
const float G = ${f(G)};
const float SRC_Y = ${f(SRC_Y)};
const float V_IN = ${f(V_IN)};
const float U0 = 0.60; /* the sheet's speed leaving the impact, H/s */
const float LF = 0.30; /* friction: the sheet has slowed to half speed by here */
const float TAU = 1.60; /* cooling time, s */
const float FREEZE = 0.27; /* the temperature it stops flowing at */
const float LIP = 0.05; /* over a corner it drops as if it had already fallen this far */
const float HEAT = 1.45; /* film energy at the impact, in rim-ramp units */
const float SURGE = ${f(SURGE)};
/* ---- the two ramps: energy → sRGB, one colour per stop ----
In the base, the rim's is a cooling liquid — deep red, orange, a long
gold, yellow, white — and the stream's a red body whose core runs pink
before it runs white. Everything else in this file is producing energy. */
vec3 rimRamp(float e) {
vec3 c = mix(vec3(0.0), u_rim[0], clamp(e / 0.1, 0.0, 1.0));
c = mix(c, u_rim[1], clamp((e - 0.10) / 0.10, 0.0, 1.0));
c = mix(c, u_rim[2], clamp((e - 0.20) / 0.15, 0.0, 1.0));
c = mix(c, u_rim[3], clamp((e - 0.35) / 0.15, 0.0, 1.0));
c = mix(c, u_rim[4], clamp((e - 0.50) / 0.20, 0.0, 1.0));
c = mix(c, u_rim[5], clamp((e - 0.70) / 0.15, 0.0, 1.0));
c = mix(c, u_rim[6], clamp((e - 0.85) / 0.15, 0.0, 1.0));
c = mix(c, u_rim[7], clamp((e - 1.00) / 0.20, 0.0, 1.0));
c = mix(c, u_rim[8], clamp((e - 1.20) / 0.20, 0.0, 1.0));
c = mix(c, u_rim[9], clamp((e - 1.40) / 0.30, 0.0, 1.0));
c = mix(c, u_rim[10], clamp((e - 1.70) / 0.30, 0.0, 1.0));
c = mix(c, u_rim[11], clamp((e - 2.00) / 0.50, 0.0, 1.0));
c = mix(c, u_rim[12], clamp((e - 2.50) / 0.50, 0.0, 1.0));
c = mix(c, u_rim[13], clamp((e - 3.00) / 0.60, 0.0, 1.0));
return mix(c, vec3(1.0), clamp((e - 3.60) / 0.60, 0.0, 1.0));
}
vec3 beamRamp(float e) {
vec3 c = mix(vec3(0.0), u_beam[0], clamp(e / 0.105, 0.0, 1.0));
c = mix(c, u_beam[1], clamp((e - 0.105) / 0.18, 0.0, 1.0));
c = mix(c, u_beam[2], clamp((e - 0.285) / 0.276, 0.0, 1.0));
c = mix(c, u_beam[3], clamp((e - 0.561) / 0.307, 0.0, 1.0));
c = mix(c, u_beam[4], clamp((e - 0.868) / 0.132, 0.0, 1.0));
c = mix(c, u_beam[5], clamp((e - 1.00) / 0.15, 0.0, 1.0));
c = mix(c, u_beam[6], clamp((e - 1.15) / 0.15, 0.0, 1.0));
c = mix(c, u_beam[7], clamp((e - 1.30) / 0.15, 0.0, 1.0));
return mix(c, u_beam[8], clamp((e - 1.45) / 0.20, 0.0, 1.0));
}
vec3 screen(vec3 a, vec3 b) { return 1.0 - (1.0 - a) * (1.0 - b); }
/* Seconds for the stream to fall from its source to height y. */
float fallTime(float y) {
float d = max(SRC_Y - y, 0.0);
return (sqrt(V_IN * V_IN + 2.0 * G * d) - V_IN) / G;
}
/* The pour rate over time: steady, with a slow swell and a surge every
SURGE seconds, each a different size. Everything below reads it at the
moment its own light left the source, so a surge rides down the stream,
out along the rim and over the corner at the liquid's own speed, and a
hot one pushes the freeze front out as it arrives. */
float flux(float t) {
float swell = sin(t * 0.83) * sin(t * 0.31 + 1.3);
float cell = floor(t / SURGE);
float ph = (fract(t / SURGE) - 0.5) * SURGE;
float size = 0.45 + 0.55 * fract(cell * 0.618034 + 0.3);
return 0.97 + 0.05 * swell + 0.28 * size * exp(-ph * ph / 0.026);
}
/* The film at arc length s from the impact, on a flank whose top run is
\`run\` long: its heat, in rim-ramp units. The sheet decelerates along
the top (u = U0 / (1 + s/LF), so its travel time grows as s + s²/2LF),
then drops over the lip and accelerates down the flank under gravity.
Temperature is the time since landing, cooled. The film is thicker where
it's slow (continuity: thickness goes as 1/speed), so along the top the
pile-up holds the glow nearly level while it cools, until it freezes. */
float film(float s, float run, float r, float tLand) {
float sTop = min(s, run);
float u = U0 / (1.0 + sTop / LF);
float age = (sTop + sTop * sTop / (2.0 * LF)) / U0;
float v = u;
float over = s - run; /* round the corner and down */
if (over > 0.0) {
float lip = LIP * smoothstep(0.0, r * 1.5708 + 0.02, over);
v = sqrt(u * u + 2.0 * G * (over + lip));
age += (v - u) / G;
}
float T = exp(-age / TAU) * flux(u_time - tLand - age);
float thick = pow(U0 / v, 0.55);
return HEAT * T * thick * smoothstep(FREEZE, FREEZE * 1.78, T);
}
float segDist(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a, ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-8), 0.0, 1.0);
return length(pa - ba * h);
}
void main() {
vec2 res = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_scale / res; /* 0..1, y up */
float aspect = res.x / res.y;
vec2 P = vec2(uv.x * aspect, uv.y); /* frame heights */
float px = 1.0 / res.y; /* one css pixel */
/* ---- the surface ---- */
vec2 bmin = vec2(u_box.x * aspect, u_box.y);
vec2 bmax = vec2(u_box.z * aspect, u_box.w);
vec2 bc = (bmin + bmax) * 0.5;
vec2 bh = (bmax - bmin) * 0.5;
float r = clamp(u_radius, 0.002, min(bh.x, bh.y));
vec2 dq = abs(P - bc) - (bh - r);
float sd = length(max(dq, 0.0)) + min(max(dq.x, dq.y), 0.0) - r;
float topY = bmax.y;
float tLand = max(fallTime(topY), 0.05); /* the whole fall */
float xi = clamp(u_pour * aspect, bmin.x + r, bmax.x - r);
vec3 col = u_ground;
if (sd < 0.0) col = u_panel;
/* ---- the rim, in flow coordinates: X runs away from the impact along
whichever flank this pixel belongs to ---- */
float side = P.x >= xi ? 1.0 : -1.0;
float X = (P.x - xi) * side;
float run = max(side > 0.0 ? bmax.x - r - xi : xi - bmin.x - r, 0.0);
float flank = max(topY - bmin.y - 2.0 * r, 0.0);
float sBottom = run + r * 1.5708 + flank; /* where the flank ends */
float s, d, onTop = 0.0, cling = 1.0;
vec2 rel = vec2(X - run, P.y - (topY - r)); /* from the top corner's centre */
if (rel.x <= 0.0 && (rel.y >= 0.0 || (topY - P.y < run + r - X && topY - P.y < P.y - bmin.y))) {
s = X; d = P.y - topY; onTop = 1.0; /* the top run */
} else if (rel.x > 0.0 && rel.y > 0.0) {
float a = atan(rel.x, rel.y); /* 0 at the top, pi/2 on the flank */
s = run + r * a; d = length(rel) - r; onTop = 1.0 - a / 1.5708;
} else {
vec2 relB = vec2(X - run, P.y - (bmin.y + r)); /* from the bottom corner's centre */
if (relB.y >= 0.0) {
s = run + r * 1.5708 + (topY - r - P.y); d = X - run - r; /* the flank */
} else if (relB.x > 0.0) {
float a = atan(relB.x, -relB.y); /* pi/2 on the flank, 0 underneath */
s = sBottom + r * (1.5708 - a); d = length(relB) - r;
cling = exp(-r * (1.5708 - a) / 0.012); /* it leaves the corner rather than turning it */
} else {
s = sBottom + r * 1.5708 + (run - X); d = bmin.y - P.y;
cling = 0.0;
}
}
float qLand = flux(u_time - tLand);
float E = film(s, run, r, tLand) * cling;
E += 2.7 * qLand * exp(-s * s / 0.0042) * onTop; /* the white-hot pool where it lands */
float amb = mix(0.2, 0.5, onTop); /* the border catching the room's light */
/* the lit edge itself, centred on the boundary */
float lw = max(0.0022, 1.2 * px);
float line = exp(-d * d / (lw * lw));
float Fr = (E + amb) * line;
/* its glow, outside the panel only, taking over from the line as the
line fades. Hotter film blooms wider, and the top run — a pool with the
splash over it — wider than a falling flank. */
if (d > 0.0) {
float lam = 0.016 * (0.7 + 0.3 * E) * (1.0 + 0.9 * onTop);
Fr += 1.2 * E * exp(-d / lam) * exp(-pow(d / 0.042, 4.0)) * (1.0 - line);
}
/* the splash lights the air around where it lands: a low, wide glow of
the sheet hugging the edge, and a taller one over the impact. Both fall
off with distance, so near a corner they wrap it rather than stopping
at the edge's height; the panel itself hides them. */
float hAbove = P.y - topY;
float fx = abs(P.x - xi);
float Fb = 0.0;
if (sd > 0.0) {
float wx = fx / 0.14;
Fr += 1.2 * qLand * exp(-wx * wx) * exp(-abs(hAbove) / 0.035) * (1.0 - exp(-sd / 0.02));
Fb += 1.0 * qLand * exp(-hAbove * hAbove / 0.00527) * exp(-pow(fx / 0.05, 1.3));
}
/* ---- the stream: a tight core in a softer body. The body spreads as it
falls and both brighten over the last stretch, lit by the splash ---- */
if (hAbove > 0.0) {
float fall = fallTime(P.y);
float dx = P.x - xi;
float nearC = exp(-hAbove / 0.08);
float sigB = mix(0.028, 0.046, smoothstep(0.28, 0.52, SRC_Y - P.y)) + 0.03 * exp(-hAbove / 0.06);
float core = 0.45 * (1.0 + 0.6 * nearC) * exp(-dx * dx / 0.000289);
float body = 0.57 * (1.0 + 0.8 * nearC) * exp(-dx * dx / (sigB * sigB));
Fb += flux(u_time - fall) * (core + body);
}
/* ---- splash droplets: ballistic hops off the impact, falling back onto
the edge they came from ---- */
if (abs(P.x - xi) < 0.3 && hAbove > -0.01 && hAbove < 0.14) {
float sw = max(0.0012, 0.7 * px);
for (int i = 0; i < 12; i++) {
float fi = float(i);
float period = 0.8 + 0.7 * vhash(vec2(fi, 1.3));
float tt = u_time + period * vhash(vec2(fi, 5.1));
float cyc = floor(tt / period);
float a = tt - cyc * period; /* seconds since it left */
float ang = (vhash(vec2(fi, cyc)) - 0.5) * 2.3; /* from vertical, within ±66° */
float spd = 0.2 + 0.28 * vhash(vec2(cyc, fi + 9.0));
vec2 v = vec2(sin(ang), cos(ang)) * spd;
if (a > 2.0 * v.y / G) continue; /* landed */
float a0 = max(a - 0.03, 0.0);
vec2 I = vec2(xi, topY);
vec2 p1 = I + v * a - vec2(0.0, 0.5 * G * a * a);
vec2 p0 = I + v * a0 - vec2(0.0, 0.5 * G * a0 * a0);
float dd = segDist(P, p0, p1);
float heat = exp(-a / 0.3) * flux(u_time - a - tLand);
Fr += 2.2 * heat * exp(-dd * dd / (sw * sw));
}
}
/* ---- drips off the bottom corners, when the bottom is in frame ---- */
if (bmin.y > -0.05 && P.y < bmin.y + r) {
float edgeX = xi + side * (run + r * 0.55);
if (abs(P.x - edgeX) < 0.01) {
float Eb = film(sBottom, run, r, tLand);
float u = U0 / (1.0 + run / LF);
float vb = sqrt(u * u + 2.0 * G * (r * 1.5708 + flank + LIP)); /* the flank's speed at the bottom */
for (int k = 0; k < 3; k++) {
float fk = float(k);
float period = 0.55 + 0.2 * vhash(vec2(fk, side));
float a = mod(u_time + fk * 0.37, period);
float y1 = bmin.y + r * 0.2 - vb * a - 0.5 * G * a * a;
float y0 = y1 + (vb + G * a) * 0.03; /* motion-blurred over 30ms */
float dd = segDist(P, vec2(edgeX, y0), vec2(edgeX, y1));
float w = max(0.0014, 0.8 * px);
Fr += 1.6 * Eb * exp(-a / 0.45) * exp(-dd * dd / (w * w));
}
}
}
col = screen(col, u_border * line);
col = screen(col, rimRamp(Fr));
col = screen(col, beamRamp(Fb));
col += (bayer8(gl_FragCoord.xy) - 0.5) * (1.6 / 255.0);
gl_FragColor = vec4(col, 1.0);
}
`;
/* The reference layout, measured off it in uv (y up): a panel from 6.35%
to 93.4% across whose top edge sits 39.2% up the frame and whose bottom
runs out of frame, with the stream landing 75% of the way along that edge. */
const DEFAULT_BOX: [number, number, number, number] = [0.0635, -0.4, 0.934, 0.392];
const DEFAULT_RADIUS = 0.012;
const POUR_ALONG = 0.7555;
export interface FoundryFieldProps {
className?: string;
/**
* Recolour the field. Any key left out keeps the molten base's colour;
* `FOUNDRY_PALETTES` has the base and four recolours ready to pass.
*/
colors?: Partial<FoundryColors>;
/**
* CSS selector for the element the light pours onto — usually the
* product screenshot. Measured every frame, resolved against `document`.
* `null` (the default) uses the reference layout.
*/
surfaceSelector?: string | null;
/**
* Where along the surface's top edge the stream lands, 0 (left) to 1
* (right). Defaults to the reference's 0.7555; read every frame, so it can
* change without remounting.
*/
pourAt?: number;
}
export function FoundryField({
className,
colors,
surfaceSelector = null,
pourAt = POUR_ALONG,
}: FoundryFieldProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const pourRef = useRef(pourAt);
pourRef.current = pourAt;
/* Keyed on the colours' values, not the object, so an inline `colors={{…}}`
doesn't rebuild the ramps every render. The shader reads the latest
palette each frame, so a colour change lands without remounting. */
const colorKey = JSON.stringify(colors ?? {});
const palette = useMemo(() => buildPalette(JSON.parse(colorKey)), [colorKey]);
const paletteRef = useRef(palette);
paletteRef.current = palette;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
/* Looked up until found, so a surface that mounts after the field still
gets poured on. */
let surface: Element | null = null;
let radiusPx = 0;
const readSurface = () => {
if (!surface && surfaceSelector) {
surface = document.querySelector(surfaceSelector);
if (surface) radiusPx = parseFloat(getComputedStyle(surface).borderTopLeftRadius) || 0;
}
return surface;
};
let sent: Palette | null = null;
const rim = new Float32Array(14 * 3);
const beam = new Float32Array(9 * 3);
return mountShaderSurface(canvas, {
fragment: FRAG,
uniforms: ["u_ground", "u_panel", "u_border", "u_rim[0]", "u_beam[0]", "u_box", "u_radius", "u_pour"],
onInit: (gl, u) => {
if (u.u_border) gl.uniform3fv(u.u_border, BORDER);
sent = null; // a restored context needs the palette again
},
onFrame: (gl, u, s) => {
const p = paletteRef.current;
if (p !== sent) {
rim.set(p.rim.flat());
beam.set(p.beam.flat());
if (u.u_ground) gl.uniform3fv(u.u_ground, p.ground);
if (u.u_panel) gl.uniform3fv(u.u_panel, p.panel);
if (u["u_rim[0]"]) gl.uniform3fv(u["u_rim[0]"], rim);
if (u["u_beam[0]"]) gl.uniform3fv(u["u_beam[0]"], beam);
sent = p;
}
const { rect } = s;
let box = DEFAULT_BOX;
let radius = DEFAULT_RADIUS;
const b = readSurface()?.getBoundingClientRect();
if (b && b.width > 0 && b.height > 0 && rect.width > 0 && rect.height > 0) {
box = [
(b.left - rect.left) / rect.width,
1 - (b.bottom - rect.top) / rect.height,
(b.right - rect.left) / rect.width,
1 - (b.top - rect.top) / rect.height,
];
radius = radiusPx / rect.height;
}
if (u.u_box) gl.uniform4f(u.u_box, box[0], box[1], box[2], box[3]);
if (u.u_radius) gl.uniform1f(u.u_radius, radius);
const along = Math.min(Math.max(pourRef.current, 0), 1);
if (u.u_pour) gl.uniform1f(u.u_pour, box[0] + along * (box[2] - box[0]));
},
onPainted: () => canvas.setAttribute("data-shader", "on"),
onLost: () => canvas.removeAttribute("data-shader"),
stillTime: STILL,
maxPixels: 1_600_000,
dprCap: 1.5,
});
}, [surfaceSelector]);
/* The floor's colours, taken off the same ramps as the shader. */
const { rim, beam } = palette;
const floor = {
"--fd-ground": css(palette.ground),
"--fd-panel": css(palette.panel),
"--fd-core": css(beam[4]),
"--fd-body": css(beam[3]),
"--fd-deep": css(beam[2], 0.55),
"--fd-edge": css(rim[2]),
"--fd-glow": css(rim[6]),
"--fd-glow-a": css(rim[6], 0.7),
"--fd-hot": css(rim[10]),
"--fd-white": css(rim[13]),
"--fd-gold-a": css(rim[9], 0.6),
} as CSSProperties;
return (
<div
className={`absolute inset-0 overflow-hidden${className ? ` ${className}` : ""}`}
style={floor}
aria-hidden="true"
>
{/* The floor: the reference's still frame in CSS — the stream, its
splash, and the panel with its lit top edge and near flank. */}
<div className="absolute inset-0 bg-[color:var(--fd-ground)]" />
<div className="absolute left-[72.5%] top-0 h-[60.8%] w-[3px] -translate-x-1/2 bg-[color:var(--fd-core)] shadow-[0_0_10px_3px_var(--fd-body),0_0_32px_10px_var(--fd-deep)]" />
<div className="absolute left-[72.5%] top-[60.8%] h-24 w-56 -translate-x-1/2 -translate-y-1/2 rounded-full [background:radial-gradient(closest-side,var(--fd-white),var(--fd-gold-a)_35%,transparent)]" />
<div className="absolute left-[6.35%] right-[6.6%] top-[60.8%] -bottom-8 rounded-[0.6rem] border border-[color:var(--fd-edge)] border-r-[color:var(--fd-glow)] border-t-[color:var(--fd-hot)] bg-[color:var(--fd-panel)] shadow-[0_-6px_22px_-6px_var(--fd-glow-a)]" />
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full opacity-0 transition-opacity duration-[--duration-slow] ease-[--ease-standard] data-[shader=on]:opacity-100" />
</div>
);
}
/* The four recolours as ready-made fields — what the catalogue renders. */
type FoundryPresetProps = Omit<FoundryFieldProps, "colors">;
export const CobaltFoundryField = (p: FoundryPresetProps) => <FoundryField {...p} colors={FOUNDRY_PALETTES.cobalt} />;
export const VerdigrisFoundryField = (p: FoundryPresetProps) => <FoundryField {...p} colors={FOUNDRY_PALETTES.verdigris} />;
export const MagentaFoundryField = (p: FoundryPresetProps) => <FoundryField {...p} colors={FOUNDRY_PALETTES.magenta} />;
export const VioletFoundryField = (p: FoundryPresetProps) => <FoundryField {...p} colors={FOUNDRY_PALETTES.violet} />;Custom work
Need one that isn't in the catalogue?
Describe what you're after and I'll reply by email — no promise on turnaround yet, this is a new channel, not a service with a set price or queue.
Fardin Omor Afnan
Reads and answers every request himself