类型:3D · 分类:Buttons · 收录:2026-09-16 20:35:01 · 源码:会员可见
付费可阅读内容 · 可复制
# Liquid Metal Button
A prismatic liquid-metal control in Sign up pill, Liquid Orb, and configurable Play Circle variants, with pointer-following bloom and press ripples.
> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/liquid-metal-button · 预览动画:https://threeui.com/previews/liquid-metal-button.webm
> 分类:Buttons
> 包含变体(3):play-circle、pill、circle
/* ===== src/shaders/liquid-metal-button/LiquidMetalButton.tsx (tsx) ===== */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import liquidMetalButtonSource from "./liquid-metal-button.html?raw";
export type LiquidMetalButtonVariant = "pill" | "circle" | "play";
export type LiquidMetalButtonProps = {
variant?: LiquidMetalButtonVariant;
className?: string;
rendering?: "colored" | "monotone";
diameter?: number;
strokeWidth?: number;
text?: string;
embedded?: boolean;
onClick?: () => void;
};
const LIQUID_METAL_BUTTON_BRIDGE = `
<script id="liquid-metal-button-bridge">
window.addEventListener('message', event => {
if(event.source !== parent) return;
const config = event.data && event.data.liquidMetalButton;
if(!config) return;
const text = typeof config.text === 'string' ? config.text.slice(0, 24) : '';
const label = btn.querySelector('.lbl');
if(label) label.textContent = text;
btn.setAttribute('aria-label', text || 'Button');
if(Number.isFinite(config.pillWidthUnits)) {
stage.style.setProperty('--bw', 'calc(' + config.pillWidthUnits + ' * var(--u))');
}
document.body.style.background = config.embedded ? '#0e0f12' : '';
stage.style.position = config.embedded ? 'absolute' : '';
stage.style.top = config.embedded ? '50%' : '';
stage.style.left = config.embedded ? '50%' : '';
stage.style.transform = config.embedded ? 'translate(-50%, -50%)' : '';
});
btn.addEventListener('click', () => {
parent.postMessage({ liquidMetalButton: { type: 'activate' } }, '*');
});
</script>`;
const CIRCLE_RUNTIME_STYLE = `
<style id="liquid-metal-circle-variant">
body[data-shape="circle"] .stage {
--h: clamp(56px, 10vmin, 72px);
--bw: var(--h);
}
body[data-shape="circle"] .btn {
gap: 0;
}
body[data-shape="circle"] .btn .ico {
width: 28%;
height: 28%;
}
body[data-shape="circle"] .btn .lbl {
display: none;
}
</style>`;
function sourceForVariant(variant: Exclude<LiquidMetalButtonVariant, "play">) {
if (variant === "pill") {
return liquidMetalButtonSource.replace("</body>", `${LIQUID_METAL_BUTTON_BRIDGE}n</body>`);
}
return liquidMetalButtonSource
.replace("</head>", `${CIRCLE_RUNTIME_STYLE}n</head>`)
.replace("<body>", '<body data-shape="circle">')
.replace(
'<button class="btn" id="btn" type="button">',
'<button class="btn" id="btn" type="button" aria-label="Add">',
)
.replace("</body>", `${LIQUID_METAL_BUTTON_BRIDGE}n</body>`);
}
const liquidMetalPlayButtonSource = liquidMetalButtonSource
.replace(
"--bw: calc(1407 * var(--u));",
"--bw: var(--h);",
)
.replace(
"</style>",
`
/* Circular play-button adapter. The renderer and interaction graph stay
source-exact; only geometry, finish, outline, and accessible naming vary. */
body{position:relative}
.stage{
--h:88px;
position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%);
}
#fx{filter:none}
.btn{flex-direction:column;gap:0}
.btn:focus-visible{outline:2px solid rgba(255,255,255,.68);outline-offset:4px}
.btn .ico{
width:calc(var(--h) * .25);height:calc(var(--h) * .25);
transform:translateX(calc(var(--h) * .018));
}
</style>`,
)
.replace(
`<button class="btn" id="btn" type="button">
<svg class="ico" viewBox="0 0 115 115" aria-hidden="true">
<g stroke="currentColor" stroke-width="17" stroke-linecap="round">
<path d="M57.5 8.5 V106.5"/>
<path d="M8.5 57.5 H106.5"/>
</g>
</svg>
<span class="lbl">Sign up</span>
</button>`,
`<button class="btn" id="btn" type="button" aria-label="Play">
<svg class="ico" viewBox="0 0 48 48" aria-hidden="true">
<path fill="currentColor" d="M15.5 10.75a2.2 2.2 0 0 1 3.32-1.9l18.04 13.25a2.35 2.35 0 0 1 0 3.8L18.82 39.15a2.2 2.2 0 0 1-3.32-1.9v-26.5Z"/>
</svg>
</button>`,
)
.replace(
"let needResize = true;",
"let needResize = true;nlet playStrokeWidth = 3;",
)
.replace(
"const bw = Math.max(1.5, 3.2 * (BH/516)); // stroke half-width, device px",
"const bw = Math.max(0.5 * DPR, playStrokeWidth * DPR * 0.5); // configurable stroke half-width, device px",
)
.replace(
"window.__seek = v => { clock = v; drawn = null; };",
`window.__seek = v => { clock = v; drawn = null; };
window.addEventListener('message', event => {
if(event.source !== parent) return;
const config = event.data && event.data.liquidMetalPlayButton;
if(!config) return;
const diameter = Math.min(160, Math.max(72, Number(config.diameter) || 88));
const strokeWidth = Math.min(8, Math.max(1, Number(config.strokeWidth) || 3));
const text = typeof config.text === 'string' ? config.text.slice(0, 24) : 'Play';
stage.style.setProperty('--h', diameter + 'px');
playStrokeWidth = strokeWidth;
btn.setAttribute('aria-label', text.trim() || 'Play');
cv.style.filter = config.rendering === 'monotone' ? 'grayscale(1) contrast(1.04)' : 'none';
needResize = true;
drawn = null;
});`,
)
.replace("</body>", `${LIQUID_METAL_BUTTON_BRIDGE}n</body>`);
function clamp(value: number, min: number, max: number, fallback: number) {
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
}
export function LiquidMetalButton({
className = "",
variant = "pill",
rendering = "colored",
diameter = 88,
strokeWidth = 3,
text,
embedded = false,
onClick,
}: LiquidMetalButtonProps) {
const hostRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLIFrameElement>(null);
const intersectsRef = useRef(true);
const [mounted, setMounted] = useState(true);
const [ready, setReady] = useState(false);
const safeVariant: LiquidMetalButtonVariant =
variant === "circle" || variant === "play" ? variant : "pill";
const isPlayButton = safeVariant === "play";
const safeText = String(text ?? (safeVariant === "pill" ? "Sign up" : safeVariant === "circle" ? "Add" : "Play"))
.slice(0, 24);
const pillWidthUnits = safeVariant === "pill"
? Math.min(3000, Math.max(1407, 820 + safeText.length * 94))
: undefined;
const source = useMemo(
() => isPlayButton ? liquidMetalPlayButtonSource : sourceForVariant(safeVariant),
[isPlayButton, safeVariant],
);
const playConfig = {
diameter: clamp(diameter, 72, 160, 88),
strokeWidth: clamp(strokeWidth, 1, 8, 3),
rendering,
text: safeText,
} as const;
const syncButtonConfig = useCallback(() => {
frameRef.current?.contentWindow?.postMessage({
liquidMetalButton: { text: safeText, pillWidthUnits, embedded },
}, "*");
}, [embedded, pillWidthUnits, safeText]);
const syncPlayConfig = useCallback(() => {
if (!isPlayButton) return;
frameRef.current?.contentWindow?.postMessage({ liquidMetalPlayButton: playConfig }, "*");
}, [isPlayButton, playConfig.diameter, playConfig.rendering, playConfig.strokeWidth, playConfig.text]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const sync = () => setMounted(intersectsRef.current && document.visibilityState !== "hidden");
const observer = new IntersectionObserver(([entry]) => {
intersectsRef.current = entry.isIntersecting;
sync();
}, { rootMargin: "80px" });
observer.observe(host);
document.addEventListener("visibilitychange", sync);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}, []);
useEffect(() => {
if (!mounted) setReady(false);
}, [mounted]);
useEffect(() => {
if (!ready) return;
syncButtonConfig();
syncPlayConfig();
}, [ready, syncButtonConfig, syncPlayConfig]);
useEffect(() => {
if (!onClick) return undefined;
const receiveMessage = (event: MessageEvent) => {
if (event.source !== frameRef.current?.contentWindow) return;
if (event.data?.liquidMetalButton?.type !== "activate") return;
onClick();
};
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onClick]);
return (
<div
ref={hostRef}
className={`liquid-metal-button${className ? ` ${className}` : ""}`}
data-state={!mounted ? "paused" : ready ? "ready" : "loading"}
data-variant={safeVariant}
>
{mounted ? (
<iframe
key={safeVariant}
ref={frameRef}
className={`liquid-metal-button__frame${ready ? " is-ready" : ""}`}
title={safeVariant === "circle"
? "Interactive liquid metal circle button"
: isPlayButton
? "Interactive liquid metal play button"
: "Interactive liquid metal button"}
srcDoc={source}
sandbox="allow-scripts"
loading="eager"
onLoad={() => {
setReady(true);
syncButtonConfig();
syncPlayConfig();
}}
/>
) : null}
</div>
);
}
/* ===== src/shaders/liquid-metal-button/liquid-metal-button.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Liquid Metal Button</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
/* A shadow needs something to fall on: a soft ambient pool lifts the
ground just off black so the button reads as sitting above it. */
background:
radial-gradient(46vmax 32vmax at 50% 47%,
#191b21 0%, #0e0f13 34%, #050506 62%, #000 88%) #000;
display:grid;place-items:center;
overflow:hidden;
font-family:"Inter",-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
font-optical-sizing:auto;
-webkit-font-smoothing:antialiased;
}
/* Everything is expressed in the reference's own units (the pill measured
1407 x 516 there), scaled off one ergonomic knob: the button height. */
.stage{
--h: 52px; /* a normal control height */
--u: calc(var(--h) / 516);
--bw: calc(1407 * var(--u));
--bh: var(--h);
/* The canvas is the stage, so this has to clear the bloom's full reach
(~4 sigma) or the halo gets cut off against a visible rectangle. */
--pad: calc(900 * var(--u));
position:relative;
width:calc(var(--bw) + 2 * var(--pad));
height:calc(var(--bh) + 2 * var(--pad));
display:grid;place-items:center;
touch-action:manipulation;
}
/* the button's own solid body, and the shadow it casts on the pool */
.plate{
position:absolute;
width:var(--bw);height:var(--bh);
border-radius:999px;
background:#0b0c0e;
box-shadow:
0 calc(var(--h) * 0.10) calc(var(--h) * 0.22) rgba(0,0,0,.72),
0 calc(var(--h) * 0.30) calc(var(--h) * 0.66) rgba(0,0,0,.62),
0 calc(var(--h) * 0.62) calc(var(--h) * 1.30) rgba(0,0,0,.42);
transition:box-shadow .38s cubic-bezier(.22,.61,.36,1),
background .38s cubic-bezier(.22,.61,.36,1);
}
/* deepen it while the metal is lit, so the bright face keeps its edge */
body.hot .plate{
background:#08090a;
box-shadow:
0 calc(var(--h) * 0.12) calc(var(--h) * 0.26) rgba(0,0,0,.80),
0 calc(var(--h) * 0.40) calc(var(--h) * 0.86) rgba(0,0,0,.72),
0 calc(var(--h) * 0.86) calc(var(--h) * 1.80) rgba(0,0,0,.55);
}
#fx{position:absolute;inset:0;width:100%;height:100%;display:block}
.btn{
position:relative;
width:var(--bw);height:var(--bh);
border:0;background:none;padding:0;
border-radius:999px;
display:flex;align-items:center;justify-content:center;
gap:calc(88 * var(--u));
color:#fff;
font-family:inherit;
font-weight:500;
font-size:calc(207 * var(--u));
line-height:1;
letter-spacing:0;
cursor:pointer;
-webkit-tap-highlight-color:transparent;
outline:none;
}
.btn:focus-visible{outline:calc(4 * var(--u)) solid rgba(255,255,255,.55);outline-offset:calc(10 * var(--u))}
.btn .ico{
width:calc(115 * var(--u));height:calc(115 * var(--u));
display:block;flex:none;overflow:visible;
}
/* descenders pull the flex box up — nudge back so the cap box, not the
em box, is what sits centred on the pill (measured, not guessed) */
.btn .lbl{display:block;transform:translateY(calc(2 * var(--u)))}
/* pressed: the button settles onto the surface, so the shadow tightens */
body.press .plate{
background:#070809;
box-shadow:
0 calc(var(--h) * 0.05) calc(var(--h) * 0.13) rgba(0,0,0,.82),
0 calc(var(--h) * 0.16) calc(var(--h) * 0.40) rgba(0,0,0,.70),
0 calc(var(--h) * 0.34) calc(var(--h) * 0.80) rgba(0,0,0,.50);
transition-duration:.10s;
}
</style>
</head>
<body>
<div class="stage" id="stage">
<div class="plate" aria-hidden="true"></div>
<canvas id="fx"></canvas>
<button class="btn" id="btn" type="button">
<svg class="ico" viewBox="0 0 115 115" aria-hidden="true">
<g stroke="currentColor" stroke-width="17" stroke-linecap="round">
<path d="M57.5 8.5 V106.5"/>
<path d="M8.5 57.5 H106.5"/>
</g>
</svg>
<span class="lbl">Sign up</span>
</button>
</div>
<script>
/* =====================================================================
Liquid metal — a dispersion shader.
A scalar field V is painted through a soft plateau: dark below it, blown
white inside, dark above. The plateau is evaluated once per spectral
wavelength at a slightly different height in V, so the plateau's lower
edge fringes warm (red turns on first) and its upper edge fringes cool
(red turns off first). Fringe width falls out as dispersion / |grad V|,
which is why the same shader gives razor-thin rainbow lines where the
field is pinched and huge navy-to-cyan washes where it is not.
V itself is built as a family of parallel curves — one swooping valley
repeated up the button, at a density that varies along its length — so
the ribbons stay laminar rather than turbulent. See the shader body.
===================================================================== */
const VERT = `#version 300 es
in vec2 position; void main(){ gl_Position = vec4(position,0.,1.); }`;
const HEAD = `#version 300 es
precision highp float;
out vec4 o;
uniform vec2 uC; // pill centre, device px
uniform vec2 uHalf; // pill half-extent, device px
uniform float uT; // seconds
uniform float uHover; // 0..1
uniform float uPress; // 0..1, eased
uniform vec4 uRip[3]; // xy centre (button heights, +y down), z start, w live
uniform vec4 uRipK; // speed, ring width, decay, amplitude
uniform vec4 uRipK2; // facet depth, facet count, crest sharpness, emission
uniform vec4 uPtr; // xy trailing cursor, z strength, w normalised speed
uniform vec4 uPtrK; // radius, base amplitude, speed amplitude, rim lift
#define PI 3.14159265
float sdPill(vec2 p, vec2 b, float r){
vec2 q = abs(p) - b + r;
return min(max(q.x,q.y),0.) + length(max(q,0.)) - r;
}
/* Expanding ring from each press, in button-height units. Three slots so a
quick double-tap overlaps instead of cutting the first one off.
Two things keep it from reading as a water ripple: the wavefront is
faceted rather than circular — its radius is modulated by angle, and the
facets rotate as it travels — and the crest profile is a cusp rather than
a gaussian, so it lands as a crease in sheet metal instead of a soft swell. */
float ripple(vec2 p, float t){
float sum = 0.;
for(int i = 0; i < 3; i++){
if(uRip[i].w < 0.5) continue;
float age = t - uRip[i].z;
if(age < 0. || age > 4.) continue;
vec2 rp = p - uRip[i].xy;
float facet = 1. + uRipK2.x * cos(uRipK2.y * atan(rp.y, rp.x) + age * 2.1 + float(i) * 2.4);
float x = (length(rp) - age * uRipK.x * facet) / uRipK.y;
sum += exp(-pow(abs(x) + 1e-4, uRipK2.z)) * exp(-age * uRipK.z);
}
return sum;
}
/* A soft well under the cursor. It lags behind the real pointer and swells
with speed, so moving across the button drags the metal rather than sliding
a static blob over it. */
float pointerW(vec2 p){
if(uPtr.z < 0.001) return 0.;
float d = length(p - uPtr.xy) / uPtrK.x;
return exp(-d*d) * uPtr.z;
}
/* Displacing the sample point, not the field value, is what makes this read as
liquid: the bands bulge and stretch around the cursor like a lens instead of
just getting brighter under it. */
vec2 pointerWarp(vec2 p){
float w = pointerW(p);
if(w <= 0.) return vec2(0.);
return normalize(p - uPtr.xy + vec2(1e-5)) * w * (uPtrK.y + uPtrK.z * uPtr.w);
}
`;
/* ---- the travelling rim, in its own pass so the blur below never touches it */
const FRAG_RIM = HEAD + `
uniform float uBw; // stroke half-width, device px
uniform float uE[8]; // base, hot, chroma-across, chroma-along, speed,
// topBias, press lift, ripple lift
/* Arc-length position around the pill, 0..1, starting at the right-hand
extreme and running counter-clockwise. Straight runs and caps are measured
in real length so a highlight travels at a constant speed all the way
round instead of stalling on the caps. */
float perim(vec2 d, float a, float r){
float P = 4.*a + 2.*PI*r;
float s;
if(d.x >= a){ // right cap
float th = atan(d.y, d.x - a); if(th < 0.) th += 2.*PI;
s = (th <= PI*0.5) ? r*th : P - r*(2.*PI - th);
} else if(d.x <= -a){ // left cap
float th = atan(d.y, d.x + a); if(th < 0.) th += 2.*PI;
s = r*PI*0.5 + 2.*a + r*(th - PI*0.5);
} else if(d.y >= 0.){ // top run
s = r*PI*0.5 + (a - d.x);
} else { // bottom run
s = r*PI*1.5 + 2.*a + (d.x + a);
}
return s / P;
}
// periodic bump, so a highlight wraps cleanly at s = 0
float pb(float u, float w){ u = fract(u); float x = min(u, 1.-u); return exp(-(x*x)/(w*w)); }
// travelling brightness around the rim — three lobes at different speeds and
// widths, which never quite re-align, so the light keeps re-pooling
float rimHot(float s, float t){
float v = uE[0];
v += 0.62 * pb(s - t*uE[4], 0.075);
v += 0.44 * pb(s + t*uE[4]*0.63 + 0.41, 0.135);
v += 0.30 * pb(s - t*uE[4]*0.34 + 0.73, 0.200);
return v;
}
// soft band riding the pill edge, offset per channel to fringe across the stroke
float rimBand(float sd, float off){ return 1. - smoothstep(0., uBw*1.05, abs(sd + uBw*0.55 + off)); }
void main(){
vec2 d = gl_FragCoord.xy - uC;
float sd = sdPill(d, uHalf, uHalf.y);
if(sd > uBw*2.5 || sd < -uBw*3.5){ o = vec4(0.); return; }
/* Each channel is offset both *across* the stroke and *along* it, so the rim
fringes red-outside / cyan-inside and its hue also drifts as a highlight
slides past — the two together are what read as metal rather than as a
moving white dot. */
float a = max(uHalf.x - uHalf.y, 0.);
float s = perim(d, a, uHalf.y);
float top = mix(1., 0.5 + 0.5 * (d.y / uHalf.y), uE[5]);
// pressing lifts the whole outline, and each ripple flares it again as the
// ring sweeps past — so the rim reports the press twice, once as a step and
// once as a wave running round the edge
// …and the stretch of outline nearest the cursor picks up a little too
vec2 p = vec2(d.x, -d.y) / (uHalf.y * 2.);
float lift = 1. + uPress * uE[6] + ripple(p, uT) * uE[7]
+ pointerW(p) * uPtrK.w;
o = vec4(vec3(
rimBand(sd, uE[2]) * rimHot(s + uE[3], uT),
rimBand(sd, 0. ) * rimHot(s, uT),
rimBand(sd, -uE[2]) * rimHot(s - uE[3], uT)
) * uE[1] * top * lift, 1.);
}`;
const FRAG_SCENE = HEAD + `
uniform float uP[21]; // tunables
float h21(vec2 p){
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float vn(vec2 p){
vec2 i = floor(p), f = fract(p);
f = f*f*(3.-2.*f);
float a = h21(i), b = h21(i+vec2(1,0)), c = h21(i+vec2(0,1)), d = h21(i+vec2(1,1));
return mix(mix(a,b,f.x), mix(c,d,f.x), f.y) * 2. - 1.;
}
// normalised to roughly -1..1; low gain keeps the first octave dominant, which
// is what keeps the ribbons big and smooth instead of turbulent
float fbm(vec2 p, float g){
float s = 0., a = 1., n = 0.;
for(int i=0;i<4;i++){ s += a*vn(p); n += a; p = p*2.03 + 11.7; a *= g; }
return s / n;
}
float fbm(vec2 p){ return fbm(p, 0.5); }
/* p is in button-height units, +y down, origin at the pill centre.
The bands in the reference are a *family of parallel curves*: one swooping
valley repeated up the button, dense where the light is pinched and pulled
wide open where it is not. So the field is built that way explicitly —
V = (y - valley(x)) * density(x)
— rather than hoping 2-D noise happens to produce it. Level sets of V are
all vertical translates of the same valley curve, which is what makes the
ribbons laminar and near-parallel; a density that varies along x is what makes
them crowd into razor fringes at one end and open into a broad wash at the
other. A soft plateau over V then paints them, sampled once per
wavelength at slightly offset heights, so every edge opens into a prism of
width dispersion / |grad V|. */
// smooth 1-D wiggle that drifts slowly with time
float wig(float x, float t, float seed){
return vn(vec2(x, t*0.150 + seed)) * 0.60
+ vn(vec2(x*2.07 + 4., t*0.105 + seed)) * 0.27
+ vn(vec2(x*4.30 - 7., t*0.080 + seed)) * 0.13;
}
float valleyAt(vec2 p, float t){ return wig(p.x*uP[0], t, 0.0) * uP[1]; }
float densAt (vec2 p, float t){ return uP[2] * exp(uP[3] * wig(p.x*uP[4] + 9.0, t, 2.7)); }
float surface(vec2 p, float t){
float V = (p.y - valleyAt(p,t)) * densAt(p,t);
V += uP[5] * fbm(p*vec2(0.8, 1.7)*uP[6] + vec2(t*0.05, -t*0.03), uP[17]);
return V - uP[7];
}
// One plateau per unit of V — so the density is literally bands per button height.
// A plateau rather than a step is what puts warm on the low edge and cool on
// the high edge of every ribbon.
float tone(float v){
float u = fract(v);
float e = uP[9], W = uP[10] * 0.5;
return smoothstep(0.5-W-e, 0.5-W, u) * (1. - smoothstep(0.5+W, 0.5+W+e, u));
}
vec3 spec(float t){ return clamp(vec3(1.5) - abs(4.*t - vec3(3.,2.,1.)), 0., 1.); }
void main(){
vec2 d = gl_FragCoord.xy - uC;
float sd = sdPill(d, uHalf, uHalf.y);
float pill = 1. - smoothstep(-1., 1., sd);
float S = uHalf.y * 2.; // button height, device px
float t = uT;
// rgb is premultiplied by the mask and alpha carries it, so the blur that
// follows can normalise and keep a clean edge instead of a dark vignette
if(uHover <= 0.0015 || pill <= 0.0015){ o = vec4(0., 0., 0., pill); return; }
vec2 p = vec2(d.x, -d.y) / S; // gl_FragCoord is y-up
vec2 q = p + pointerWarp(p); // the cursor drags the sheet
// self-refraction: bend the lookup along the field's own slope, which piles
// iso-lines up into folds instead of leaving them evenly spaced
float h0 = surface(q, t);
vec2 gp = vec2(dFdx(h0), -dFdy(h0)) * S; // grad in p-units
float V = surface(q - gp * uP[8] / max(uP[2], .001), t);
// gradient-aligned filaments: fast variation across the iso-lines, slow
// along them, so the fine detail reads as drawn-out fibres of light
vec2 gd = normalize(gp + vec2(1e-5));
V += uP[13] * fbm(vec2(dot(q,gd)*uP[14], dot(q, vec2(-gd.y,gd.x))*uP[14]*0.04) + vec2(0., t*0.06));
// press ripple: displacing the field rather than adding light means the
// bands themselves bow outwards as the ring passes, which is what sells it
// as a disturbance *in* the metal instead of a decal over it
float rip = ripple(p, t);
float well = pointerW(p);
V += rip * uRipK.w;
// Real dispersion is not linear in wavelength — the blue end bends far more
// than the red (Cauchy). Skewing the sample offsets the same way is what
// gives the reference its broad cool wash against a tight warm edge.
const int N = 21;
float mid = 1. - pow(0.5, uP[12]);
vec3 col = vec3(0.), wsum = vec3(0.);
for(int i=0;i<N;i++){
float k = float(i)/float(N-1);
vec3 w = spec(k);
col += w * tone(V + ((1. - pow(1. - k, uP[12])) - mid) * uP[11]);
wsum += w;
}
col /= wsum;
col = pow(col, vec3(uP[15]));
// light envelope — the ribbons only exist where the sheet is lit, and the
// dark upper region is bounded by the same valley curve the bands follow
float lit = smoothstep(uP[18], uP[19], q.y - valleyAt(q, t));
lit *= mix(1., lit, 0.55); // deepen the unlit crescent
col *= uP[16] * lit;
// the crest runs hotter, and carries a little light of its own so it stays
// legible through the softening blur and across the unlit part of the pill
col = col * (1. + rip * 1.15 + well * 0.60);
o = vec4(col * pill * uHover, pill);
}`;
/* Downsample; optionally adding a second source (used to fold the rim into
the bloom input). Alpha rides along so the metal's coverage mask survives
the blur chain. */
const FRAG_DOWN = `#version 300 es
precision highp float;
out vec4 o;
uniform sampler2D uTex, uTex2;
uniform vec2 uDstTexel; // 1 / destination size (maps dest fragCoord -> uv)
uniform vec2 uSrcTexel; // 1 / source size (tap spacing)
uniform float uAdd; // 1 to include uTex2
void main(){
vec2 uv = gl_FragCoord.xy * uDstTexel;
// Taps sit a quarter of a *destination* texel out, so for a 2x reduction
// they land exactly on the four source texel centres. Spacing them by a
// whole source texel instead — as this did originally — skips every other
// pixel, and any fine detail in the field folds down into low-frequency
// moiré that no amount of subsequent blurring can remove.
vec2 e = uDstTexel * 0.25;
vec4 s = texture(uTex, uv + vec2(-e.x,-e.y)) + texture(uTex, uv + vec2( e.x,-e.y))
+ texture(uTex, uv + vec2(-e.x, e.y)) + texture(uTex, uv + vec2( e.x, e.y));
s *= 0.25;
if(uAdd > 0.5){
vec4 r = texture(uTex2, uv + vec2(-e.x,-e.y)) + texture(uTex2, uv + vec2( e.x,-e.y))
+ texture(uTex2, uv + vec2(-e.x, e.y)) + texture(uTex2, uv + vec2( e.x, e.y));
s.rgb += r.rgb * 0.25;
}
o = s;
}`;
const FRAG_BLUR = `#version 300 es
precision highp float;
out vec4 o;
uniform sampler2D uTex; uniform vec2 uTexel; uniform vec2 uDir; uniform float uR;
void main(){
vec2 uv = gl_FragCoord.xy * uTexel;
vec2 st = uTexel * uDir * uR;
vec4 s = texture(uTex, uv) * 0.1964;
s += (texture(uTex, uv + st*1.4118) + texture(uTex, uv - st*1.4118)) * 0.2969;
s += (texture(uTex, uv + st*3.2941) + texture(uTex, uv - st*3.2941)) * 0.0944;
s += (texture(uTex, uv + st*5.1765) + texture(uTex, uv - st*5.1765)) * 0.0104;
o = s;
}`;
const FRAG_COMP = HEAD + `
uniform sampler2D uSoft, uRim, uGlow;
uniform vec2 uRes;
uniform float uGlowGain, uGlowIn, uOccl, uDim, uPunch;
void main(){
vec2 uv = gl_FragCoord.xy / uRes;
vec3 glow = texture(uGlow, uv).rgb;
vec2 d = gl_FragCoord.xy - uC;
float sd = sdPill(d, uHalf, uHalf.y);
float pill = 1. - smoothstep(-1., 1., sd);
// normalised blur: dividing by the blurred coverage keeps the softened metal
// full strength right up to the edge instead of fading into the mask
vec4 m = texture(uSoft, uv);
// Scrim, applied *after* the blur: knock the metal back through the middle
// where the label sits, leaving the top and bottom at full brightness. Doing
// this before the blur would smear the protection away at high blur values.
float veil = 1. - smoothstep(0.46, 0.88, abs(d.y) / uHalf.y);
// Blurring flattens the tonal range into a wash; putting the contrast back
// with a power curve — after the blur, so it costs no smoothness — is what
// makes it read as poured metal rather than a soft glow. Highlights keep
// their level while the mid-tones drop away.
vec3 metal = pow(max(m.rgb / max(m.a, 1e-3), 0.), vec3(uPunch));
vec3 core = metal * pill * mix(1., uDim, veil) + texture(uRim, uv).rgb;
// The ripple's own light is added here, after the blur, so the crease stays
// a hard line. Its displacement of the field still rides inside the
// softened metal — the sheet bows, and the crest glints along the fold.
float rip = ripple(vec2(d.x, -d.y) / (uHalf.y * 2.), uT);
core += vec3(rip * rip) * uRipK2.w * pill * mix(1., 0.42, veil);
// The button occludes its own bloom over the patch where its shadow falls,
// so the drop shadow keeps its contrast even when the face is blown out.
float sdSh = sdPill(d + vec2(0., uHalf.y * 0.62), uHalf * 0.94, uHalf.y * 0.94);
float occl = uOccl * exp(-max(sdSh, 0.) / (uHalf.y * 0.75));
// Bloom spills mostly outward; a little of it is allowed back inside so the
// hot rim bleeds onto the face, as it does on the reference component.
vec3 rgb = core + glow * uGlowGain * mix(1., uGlowIn, pill) * (1. - occl * (1. - pill));
// premultiplied — the page's ambient pool and the button's drop shadow are
// CSS underneath, and this layer adds light on top of them
float a = clamp(max(rgb.r, max(rgb.g, rgb.b)), 0., 1.);
o = vec4(min(rgb, vec3(1.)), a);
}`;
/* --------------------------------------------------------------- */
const cv = document.getElementById('fx');
const gl = cv.getContext('webgl2', {alpha:true, antialias:false, premultipliedAlpha:true, powerPreference:'high-performance'});
const stage = document.getElementById('stage');
const btn = document.getElementById('btn');
const plate = document.querySelector('.plate');
// the metal field — uP[0..20]
const P = window.__P = {
valFreq: 0.50, // 0 x-frequency of the valley curve
valAmp: 0.55, // 1 valley depth, in button heights (bounded so the
// ribbon can never drift entirely off the pill)
dens: 2.40, // 2 band density — bands per button height
densVar: 2.20, // 3 how much the density swings along x (exponential)
densFreq: 0.32, // 4 x-frequency of the density variation
wobAmp: 0.12, // 5 organic 2-D wobble, in field units
wobFreq: 1.60, // 6 its frequency
lift: 0.05, // 7 phase offset of the band family
refract: 0.18, // 8 self-refraction — folds the iso-lines
edge: 0.04, // 9 softness of the plateau edges
width: 0.46, // 10 plateau width, as a fraction of one band period
disp: 0.30, // 11 spectral dispersion, in band periods
skew: 1.50, // 12 dispersion skew — >1 spreads the blue end
// The filaments were 20 cycles per button height — finer than the softening
// buffer can carry, so they aliased into stripes instead of reading as
// fibres. At this blur they contribute nothing but that, so they are off.
fineAmp: 0.0, // 13 filament amplitude
fineFreq: 9.0, // 14 filament frequency across the iso-lines
gamma: 1.00, // 15 tone gamma
gain: 1.90, // 16 overall gain
octGain: 0.32, // 17 fbm octave gain — low keeps the wobble big
litLo: -0.26, // 18 distance below the valley where light begins
litHi: 0.10, // 19 …and where it is full
dim: 0.44 // 20 how far the metal is knocked back under the label
};
const PKEYS = Object.keys(P);
// the animated rim — uE[0..5]
const E = window.__E = {
base: 0.20, // 0 floor brightness, so the whole outline stays drawn
hot: 0.82, // 1 gain on the travelling highlights
chromA: 0.42, // 2 chromatic offset across the stroke, device px
chromS: 0.030, // 3 chromatic offset along the perimeter, in laps
speed: 0.070, // 4 laps per second of the leading highlight
top: 0.35, // 5 how much the rim stays biased to the top edge
press: 0.85, // 6 how far the outline brightens while held
ripple: 1.60 // 7 extra flare as a ripple crest crosses the outline
};
const EKEYS = Object.keys(E);
// composite / JS-side only
const C = window.__C = {
glow: 1.95, // outer-glow gain
glowR: 1.30, // outer-glow radius
glowIn: 0.30, // how much bloom is allowed back inside the pill
occl: 0.62, // how much the drop shadow eats the bloom beneath it
soften: 0.24, // blur on the metal, in button heights — the "molten" knob
punch: 1.50 // contrast curve on the softened metal; 1 = off
};
// disturbances — all distances in button heights, times in seconds
const R = window.__R = {
// press ripple
speed: 1.85, // how fast the ring expands
width: 0.20, // ring thickness
decay: 1.35, // e-fold fade
amp: 1.35, // how far it displaces the metal field
facet: 0.18, // depth of the faceting on the wavefront
lobes: 6.0, // how many facets
sharp: 1.15, // crest profile: 2 = gaussian swell, ~1 = hard crease
emit: 0.45, // light the crest carries of its own
// cursor well
ptrRad: 0.55, // radius of the well
ptrAmp: 0.32, // how far the sheet is dragged when the cursor is still
ptrFast: 0.40, // extra drag at full speed
ptrRim: 0.80, // how much the nearest rim brightens
ptrLag: 0.0016, // trail: fraction of the gap left after 1s (lower = snappier)
ptrVref: 4.5 // cursor speed, in button heights/sec, that counts as "fast"
};
if(!gl){
document.body.innerHTML = '<p style="color:#888;font:14px system-ui">WebGL2 is required for this page.</p>';
} else {
function sh(type, src){
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) + 'n' + src);
return s;
}
function prog(fs){
const p = gl.createProgram();
gl.attachShader(p, sh(gl.VERTEX_SHADER, VERT));
gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));
gl.bindAttribLocation(p, 0, 'position');
gl.linkProgram(p);
if(!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
const u = {};
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for(let i=0;i<n;i++){ const info = gl.getActiveUniform(p,i); u[info.name.replace('[0]','')] = gl.getUniformLocation(p, info.name); }
return {p, u};
}
const pScene = prog(FRAG_SCENE), pRim = prog(FRAG_RIM),
pDown = prog(FRAG_DOWN), pBlur = prog(FRAG_BLUR), pComp = prog(FRAG_COMP);
const vao = gl.createVertexArray(); gl.bindVertexArray(vao);
const vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const hasFloat = !!gl.getExtension('EXT_color_buffer_half_float');
function makeTarget(){
const tex = gl.createTexture();
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 fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
return {tex, fbo, w:0, h:0};
}
function sizeTarget(t, w, h){
if(t.w === w && t.h === h) return;
t.w = w; t.h = h;
gl.bindTexture(gl.TEXTURE_2D, t.tex);
if(hasFloat) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
}
const T_core = makeTarget(), T_rim = makeTarget(), // full res
T_s1 = makeTarget(), T_s2 = makeTarget(), // half res: metal softening
T_a = makeTarget(), T_b = makeTarget(); // 1/DOWN: bloom
let W = 0, H = 0, DPR = 1, BW = 0, BH = 0, CX = 0, CY = 0;
// The bloom buffer is downsampled to keep the button ~129 texels tall at any
// size, so one set of blur radii gives a glow of the same *relative* extent
// whether this renders at 52px or as a hero.
let DOWN = 4;
const GLOW_TEX = 129;
let needResize = true;
function resize(){
const r = stage.getBoundingClientRect();
const br = btn.getBoundingClientRect();
DPR = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(2, Math.round(r.width * DPR));
const h = Math.max(2, Math.round(r.height * DPR));
if(w !== W || h !== H){ W = w; H = h; cv.width = W; cv.height = H; }
BW = br.width * DPR; BH = br.height * DPR;
CX = (br.left - r.left) * DPR + BW/2;
CY = H - ((br.top - r.top) * DPR + BH/2); // gl_FragCoord is y-up
sizeTarget(T_core, W, H); sizeTarget(T_rim, W, H);
const hw = Math.max(2, Math.ceil(W/2)), hh = Math.max(2, Math.ceil(H/2));
sizeTarget(T_s1, hw, hh); sizeTarget(T_s2, hw, hh);
DOWN = Math.max(1, Math.min(4, Math.round(BH / GLOW_TEX)));
const dw = Math.max(2, Math.ceil(W/DOWN)), dh = Math.max(2, Math.ceil(H/DOWN));
sizeTarget(T_a, dw, dh); sizeTarget(T_b, dw, dh);
needResize = false;
}
new ResizeObserver(() => { needResize = true; }).observe(stage);
function drawTo(t){
gl.bindFramebuffer(gl.FRAMEBUFFER, t ? t.fbo : null);
gl.viewport(0, 0, t ? t.w : W, t ? t.h : H);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
const uArr = new Float32Array(PKEYS.length);
const eArr = new Float32Array(EKEYS.length);
let hover = 0, hoverTarget = 0, clock = 0, last = performance.now();
// three ripple slots, reused round-robin so rapid taps overlap
const RIP = [0,1,2].map(() => ({x:0, y:0, t:-99, on:0}));
const ripArr = new Float32Array(12);
let ripNext = 0, press = 0, pressTarget = 0;
// the cursor well: a target the metal chases, plus how fast it is being moved
const ptr = {x:0, y:0}, ptrS = {x:0, y:0};
let ptrAmt = 0, ptrSpeed = 0;
function addRipple(x, y){
const r = RIP[ripNext];
ripNext = (ripNext + 1) % RIP.length;
r.x = x; r.y = y; r.t = clock; r.on = 1;
}
// pointer position -> button-height units from the pill centre, +y down
function localPt(e){
const b = btn.getBoundingClientRect(), s = b.height;
return [(e.clientX - (b.left + b.width/2)) / s,
(e.clientY - (b.top + b.height/2)) / s];
}
const calm = matchMedia('(prefers-reduced-motion: reduce)');
let drawn = null; // signature of the last frame actually drawn
function frame(now){
const dtRaw = (now - last) / 1000; last = now;
const dt = Math.min(dtRaw, 1/20);
if(!calm.matches) clock += dt;
// asymmetric ease: quick to bloom, a touch quicker to die
const k = hoverTarget > hover ? 1 - Math.pow(0.0012, dt) : 1 - Math.pow(0.00012, dt);
hover += (hoverTarget - hover) * k;
if(Math.abs(hoverTarget - hover) < 0.0008) hover = hoverTarget;
// press snaps on and lets go slowly
const pk = pressTarget > press ? 1 - Math.pow(1e-9, dt) : 1 - Math.pow(0.004, dt);
press += (pressTarget - press) * pk;
if(Math.abs(pressTarget - press) < 0.002) press = pressTarget;
for(let i = 0; i < RIP.length; i++){
const r = RIP[i];
if(r.on && clock - r.t > 4) r.on = 0;
ripArr[i*4] = r.x; ripArr[i*4+1] = r.y; ripArr[i*4+2] = r.t; ripArr[i*4+3] = r.on;
}
const ripLive = RIP.some(r => r.on);
// the well trails the cursor and swells with how fast it is being dragged
const lag = 1 - Math.pow(R.ptrLag, dt);
const dx = (ptr.x - ptrS.x) * lag, dy = (ptr.y - ptrS.y) * lag;
ptrS.x += dx; ptrS.y += dy;
const inst = Math.min(Math.hypot(dx, dy) / Math.max(dt, 1e-3) / R.ptrVref, 1);
ptrSpeed += (inst - ptrSpeed) * (1 - Math.pow(inst > ptrSpeed ? 0.001 : 0.02, dt));
const wantWell = (on.over || on.press) ? 1 : 0;
ptrAmt += (wantWell - ptrAmt) * (1 - Math.pow(0.004, dt));
if(Math.abs(wantWell - ptrAmt) < 0.002) ptrAmt = wantWell;
if(needResize) resize();
// The rim keeps travelling even at rest, so the only truly static case is
// reduced motion with nothing in flight.
const sig = (calm.matches && !ripLive && ptrAmt < 0.002)
? `${hover}|${press}|${W}|${H}` : null;
if(sig !== null && sig === drawn){ requestAnimationFrame(frame); return; }
drawn = sig;
for(let i = 0; i < uArr.length; i++) uArr[i] = P[PKEYS[i]];
for(let i = 0; i < eArr.length; i++) eArr[i] = E[EKEYS[i]];
const bw = Math.max(1.5, 3.2 * (BH/516)); // stroke half-width, device px
// 1. metal + travelling rim, masked to the pill
gl.useProgram(pScene.p);
gl.uniform2f(pScene.u.uC, CX, CY);
gl.uniform2f(pScene.u.uHalf, BW/2, BH/2);
gl.uniform1f(pScene.u.uT, clock);
gl.uniform1f(pScene.u.uHover, hover);
gl.uniform1f(pScene.u.uPress, press);
gl.uniform4fv(pScene.u.uRip, ripArr);
gl.uniform4f(pScene.u.uRipK, R.speed, R.width, R.decay, R.amp);
gl.uniform4f(pScene.u.uRipK2, R.facet, R.lobes, R.sharp, R.emit);
gl.uniform4f(pScene.u.uPtr, ptrS.x, ptrS.y, ptrAmt, ptrSpeed);
gl.uniform4f(pScene.u.uPtrK, R.ptrRad, R.ptrAmp, R.ptrFast, R.ptrRim);
gl.uniform1fv(pScene.u.uP, uArr);
drawTo(T_core);
// 2. rim, kept out of the softening blur so the outline stays razor thin
gl.useProgram(pRim.p);
gl.uniform2f(pRim.u.uC, CX, CY);
gl.uniform2f(pRim.u.uHalf, BW/2, BH/2);
gl.uniform1f(pRim.u.uT, clock);
gl.uniform1f(pRim.u.uBw, bw);
gl.uniform1f(pRim.u.uPress, press);
gl.uniform4fv(pRim.u.uRip, ripArr);
gl.uniform4f(pRim.u.uRipK, R.speed, R.width, R.decay, R.amp);
gl.uniform4f(pRim.u.uRipK2, R.facet, R.lobes, R.sharp, R.emit);
gl.uniform4f(pRim.u.uPtr, ptrS.x, ptrS.y, ptrAmt, ptrSpeed);
gl.uniform4f(pRim.u.uPtrK, R.ptrRad, R.ptrAmp, R.ptrFast, R.ptrRim);
gl.uniform1fv(pRim.u.uE, eArr);
drawTo(T_rim);
// 3. soften the metal — half-res box down, then a separable gaussian. This
// is what turns the prismatic ribbons molten rather than etched.
gl.useProgram(pDown.p);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, T_core.tex);
gl.uniform1i(pDown.u.uTex, 0);
gl.uniform1f(pDown.u.uAdd, 0);
gl.uniform2f(pDown.u.uDstTexel, 1/T_s1.w, 1/T_s1.h);
gl.uniform2f(pDown.u.uSrcTexel, 1/W, 1/H);
drawTo(T_s1);
gl.useProgram(pBlur.p);
gl.uniform1i(pBlur.u.uTex, 0);
gl.uniform2f(pBlur.u.uTexel, 1/T_s1.w, 1/T_s1.h);
// Target sigma in half-res texels, tied to the button so it scales with any
// size. One very wide 9-tap pass leaves visible comb ghosts — the taps end
// up further apart than the sigma they are meant to describe — so the blur
// is split into passes whose radii add in quadrature.
const sigTex = C.soften * (BH * 0.5) * 0.95;
if(sigTex > 0.1){
const iters = Math.min(4, Math.max(1, Math.ceil(sigTex / 3.0)));
gl.uniform1f(pBlur.u.uR, sigTex / Math.sqrt(iters) / 1.95);
for(let i = 0; i < iters; i++){
gl.bindTexture(gl.TEXTURE_2D, T_s1.tex); gl.uniform2f(pBlur.u.uDir, 1, 0); drawTo(T_s2);
gl.bindTexture(gl.TEXTURE_2D, T_s2.tex); gl.uniform2f(pBlur.u.uDir, 0, 1); drawTo(T_s1);
}
}
// 4. bloom, fed by the softened metal plus the crisp rim
gl.useProgram(pDown.p);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, T_s1.tex);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, T_rim.tex);
gl.uniform1i(pDown.u.uTex, 0);
gl.uniform1i(pDown.u.uTex2, 1);
gl.uniform1f(pDown.u.uAdd, 1);
gl.uniform2f(pDown.u.uDstTexel, 1/T_a.w, 1/T_a.h);
gl.uniform2f(pDown.u.uSrcTexel, 1/T_s1.w, 1/T_s1.h);
drawTo(T_a);
gl.useProgram(pBlur.p);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(pBlur.u.uTex, 0);
gl.uniform2f(pBlur.u.uTexel, 1/T_a.w, 1/T_a.h);
const rs = C.glowR * (BH / DOWN) / GLOW_TEX;
for(const r of [1.0, 2.3, 5.2, 9.0].map(v => v * rs)){
gl.uniform1f(pBlur.u.uR, r);
gl.bindTexture(gl.TEXTURE_2D, T_a.tex); gl.uniform2f(pBlur.u.uDir, 1, 0); drawTo(T_b);
gl.bindTexture(gl.TEXTURE_2D, T_b.tex); gl.uniform2f(pBlur.u.uDir, 0, 1); drawTo(T_a);
}
// 5. composite
gl.useProgram(pComp.p);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, T_s1.tex); gl.uniform1i(pComp.u.uSoft, 0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, T_rim.tex); gl.uniform1i(pComp.u.uRim, 1);
gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, T_a.tex); gl.uniform1i(pComp.u.uGlow, 2);
gl.uniform2f(pComp.u.uRes, W, H);
gl.uniform2f(pComp.u.uC, CX, CY);
gl.uniform2f(pComp.u.uHalf, BW/2, BH/2);
gl.uniform1f(pComp.u.uT, clock);
gl.uniform4fv(pComp.u.uRip, ripArr);
gl.uniform4f(pComp.u.uRipK, R.speed, R.width, R.decay, R.amp);
gl.uniform4f(pComp.u.uRipK2, R.facet, R.lobes, R.sharp, R.emit);
gl.uniform1f(pComp.u.uGlowGain, C.glow);
gl.uniform1f(pComp.u.uGlowIn, C.glowIn);
gl.uniform1f(pComp.u.uOccl, C.occl);
gl.uniform1f(pComp.u.uDim, P.dim);
gl.uniform1f(pComp.u.uPunch, C.punch);
drawTo(null);
requestAnimationFrame(frame);
}
/* ---------------- interaction ----------------
Hover, press and focus all light the metal; press additionally throws a
ripple from wherever it landed. Works for mouse, touch and keyboard. */
const on = {over:false, press:false, focus:false};
const sync = () => {
hoverTarget = (on.over || on.press || on.focus) ? 1 : 0;
pressTarget = on.press ? 1 : 0;
document.body.classList.toggle('hot', hoverTarget > 0.5);
document.body.classList.toggle('press', on.press);
};
btn.addEventListener('pointerenter', e => {
if(e.pointerType !== 'mouse') return;
// land the well where the cursor actually entered, not where it last was
[ptr.x, ptr.y] = localPt(e);
ptrS.x = ptr.x; ptrS.y = ptr.y; ptrSpeed = 0;
on.over = true; sync();
});
btn.addEventListener('pointerleave', e => { if(e.pointerType === 'mouse'){ on.over = false; sync(); } });
// the cursor drags the metal; tracked on the window so a press can slide off
// the button, but only measured while the button is actually engaged
window.addEventListener('pointermove', e => {
if(!on.over && !on.press) return;
[ptr.x, ptr.y] = localPt(e);
}, {passive:true});
btn.addEventListener('pointerdown', e => {
[ptr.x, ptr.y] = localPt(e);
on.press = true; sync();
addRipple(ptr.x, ptr.y);
});
window.addEventListener('pointerup', () => { on.press = false; sync(); });
window.addEventListener('pointercancel', () => { on.press = false; sync(); });
// only keyboard focus keeps it lit — a mouse click shouldn't leave the button
// glowing after the pointer has moved away
btn.addEventListener('focus', () => {
on.focus = btn.matches(':focus-visible'); sync();
});
btn.addEventListener('blur', () => { on.focus = false; sync(); });
// keyboard activation gets the same treatment, rippling from the centre
btn.addEventListener('keydown', e => {
if(e.key !== 'Enter' && e.key !== ' ' || e.repeat) return;
on.press = true; sync(); addRipple(0, 0);
});
btn.addEventListener('keyup', e => {
if(e.key !== 'Enter' && e.key !== ' ') return;
on.press = false; sync();
});
resize();
requestAnimationFrame(frame);
// tiny console hooks for tuning
window.__set = (o = {}, e = {}, c = {}, r = {}) => {
Object.assign(P, o); Object.assign(E, e); Object.assign(C, c); Object.assign(R, r);
drawn = null;
};
window.__hover = v => { on.over = !!v; sync(); };
window.__press = v => { on.press = !!v; sync(); if(v) addRipple(0, 0); };
window.__ripple = (x = 0, y = 0) => addRipple(x, y);
window.__seek = v => { clock = v; drawn = null; };
}
</script>
</body>
</html>
