Bead Button
A single bead of water on dark hydrophobic glass is the button. It magnifies the etched plate behind it and shows it upside down, the way real water does, while the label stays upright and crisp. The pointer's approach leans it, a press flattens it and pinches off a satellite droplet that re-merges, and it never runs away.
Medium: water
The button is the only wet thing here.
Rooms where exactly one thing is wrong. Press the bead; it gives, and keeps its shape.
Props
"use client";
import { useEffect, useId, useRef } from "react";
import type { ComponentPropsWithoutRef, CSSProperties } from "react";
/**
* BeadButton: a single bead of water on dark hydrophobic glass IS the button.
* It is a real <button>. Nothing about the button is wrong except what it is
* made of.
*
* HOW IT IS MADE. CSS and SVG only, no canvas, no dependencies.
* 1. The component owns its backdrop: a dark etched plate (hairline grid,
* one pool of light, a ruler, a few mono marks, static grain). The same
* <Backdrop> is drawn twice.
* 2. Inside the bead the second copy is the lens. It is rotated 180 degrees
* about the bead centre and scaled by "magnify", so the backdrop seen
* through the water is inverted and enlarged, which is the physically
* correct cue that this is water. The bead clips it.
* 3. An SVG filter bends it at the rim: a displacement map whose strength
* rises steeply towards the edge (barrel), run three times at slightly
* different strengths so the edge fringes.
* 4. The bead's own optics are layered on top: a thin dark total-internal-
* reflection band, a bright crescent on the lit rim, a bounce on the far
* rim, one specular and one speck, a body tint that deepens at the rim.
* The plate gets a contact shadow and a bright caustic crescent on the
* side away from the one light (top left). The label is upright and
* crisp above the water.
*
* ALIVE. A small spring system runs ONLY while something is moving; at rest
* no frame loop exists. The pointer's approach leans the bead (time constant
* ~0.35 s); pressing flattens and widens it while a satellite droplet pinches
* off and re-merges within ~380 ms; sweeping across the plate shoves it a few
* pixels and it springs back (non-wetting). It never moves away from the
* pointer: the click target stays where it is. Touch and pen respond on a TAP
* only. Under prefers-reduced-motion the bead is static and the pressed state
* is instant.
*
* Needs SVG filters on HTML (all current browsers). Where the displacement
* is not supported the bead still shows the inverted, magnified lens, just
* without the rim bend.
*/
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function clamp(v: number, lo: number, hi: number, fallback: number) {
return Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback;
}
const f = (n: number) => String(Math.round(n * 1000) / 1000);
export type BeadButtonSize = "sm" | "md" | "lg";
export type BeadButtonTint = "clear" | "koi" | "cobalt";
export type BeadButtonProps = Omit<ComponentPropsWithoutRef<"button">, "type" | "children" | "className" | "style" | "color"> & {
/** The words on the bead. */
label?: string;
size?: BeadButtonSize;
/** A subtle body tint: clear water, koi (warm) or cobalt. */
tint?: BeadButtonTint;
/** How strongly the bead magnifies the plate behind it, 1.1 to 1.7. */
magnify?: number;
/** Plate ground colour, a hex. */
ground?: string;
type?: "button" | "submit" | "reset";
/** Goes on the plate (the root), which centres the bead. */
className?: string;
style?: CSSProperties;
};
const SIZES: Record<BeadButtonSize, { h: number; font: number }> = {
sm: { h: 60, font: 13 },
md: { h: 84, font: 16 },
lg: { h: 116, font: 20 },
};
const TINTS: Record<BeadButtonTint, { core: string; rim: string; caustic: string; sat: string }> = {
clear: { core: "rgba(217,236,248,0)", rim: "rgba(150,190,215,0.07)", caustic: "217,236,248", sat: "rgba(120,160,185,0.20)" },
koi: { core: "rgba(255,127,30,0.03)", rim: "rgba(255,120,30,0.20)", caustic: "255,176,112", sat: "rgba(255,127,30,0.26)" },
cobalt: { core: "rgba(43,99,255,0.04)", rim: "rgba(43,99,255,0.26)", caustic: "150,184,255", sat: "rgba(43,99,255,0.30)" },
};
/* The bead is almost an ellipse, not quite: a drop on glass is never perfect. */
const SHAPE = "31% 33% 35% 32% / 46% 44% 56% 54%";
/* ---- The lens ----------------------------------------------------------
* Displacement map: red rises left to right, green top to bottom. A curve
* on each channel (|t|^2.4) leaves the middle flat and bends the rim hard,
* which is what a barrel lens does. 17 samples are plenty.
*/
const MAP_URI =
"data:image/svg+xml," +
encodeURIComponent(
[
"<svg xmlns='http://www.w3.org/2000/svg' width='128' height='128'>",
"<defs>",
"<linearGradient id='a' x1='0' y1='0' x2='1' y2='0'><stop offset='0' stop-color='#000'/><stop offset='1' stop-color='#f00'/></linearGradient>",
"<linearGradient id='b' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='#000'/><stop offset='1' stop-color='#0f0'/></linearGradient>",
"</defs>",
"<rect width='128' height='128' fill='url(#a)'/>",
"<rect width='128' height='128' fill='url(#b)' style='mix-blend-mode:screen'/>",
"</svg>",
].join(""),
);
const BARREL = Array.from({ length: 17 }, (_, i) => {
const t = (i / 16) * 2 - 1;
return (0.5 + 0.5 * Math.sign(t) * Math.pow(Math.abs(t), 2.4)).toFixed(3);
}).join(" ");
/* ---- The plate ---------------------------------------------------------
* The one thing the lens looks at. Everything is in plate pixels, anchored to
* the top-left corner or (for the marks) to the centre, so the lens copy,
* which has the same size, lines up exactly with the plate beneath it.
*/
function Backdrop({ uid, ground, k }: { uid: string; ground: string; k: number }) {
return (
<span
aria-hidden="true"
style={{
display: "block",
position: "absolute",
inset: 0,
pointerEvents: "none",
backgroundColor: ground,
backgroundImage: [
"radial-gradient(ellipse 27% 46% at 41% 40%, rgba(217,236,248,0.36), rgba(217,236,248,0.12) 48%, rgba(217,236,248,0) 80%)",
"linear-gradient(135deg, rgba(27,37,45,0.9), rgba(4,6,10,0) 72%)",
].join(","),
}}
>
<svg width="100%" height="100%" style={{ position: "absolute", inset: 0, overflow: "hidden" }}>
<defs>
<pattern id={uid + "g1"} width="16" height="16" patternUnits="userSpaceOnUse">
<path d="M16 0H0V16" fill="none" stroke="rgba(214,232,245,0.09)" strokeWidth="1" />
</pattern>
<pattern id={uid + "g2"} width="80" height="80" patternUnits="userSpaceOnUse">
<path d="M80 0H0V80" fill="none" stroke="rgba(214,232,245,0.19)" strokeWidth="1" />
</pattern>
<filter id={uid + "n"} x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="7" stitchTiles="stitch" />
<feColorMatrix values="0 0 0 0 0.85 0 0 0 0 0.92 0 0 0 0 0.97 1.6 0 0 0 -0.5" />
</filter>
</defs>
<rect width="100%" height="100%" fill={"url(#" + uid + "g1)"} />
<rect width="100%" height="100%" fill={"url(#" + uid + "g2)"} />
<rect width="100%" height="100%" filter={"url(#" + uid + "n)"} opacity="0.06" />
{/* Marks, placed from the plate centre. They read as etched into the glass. */}
<svg x="50%" y="50%" overflow="visible">
<g transform={"scale(" + k + ")"}>
<g fill="none" stroke="rgba(234,241,246,0.6)" strokeWidth="1">
<path d="M-96 21H96" />
{Array.from({ length: 20 }, (_, i) => {
const x = -95 + i * 10;
const tall = i % 5 === 0;
return <path key={i} d={"M" + x + " 21V" + (tall ? 13 : 17)} />;
})}
<path d="M-60 -22H-30M-45 -29V-15" />
<circle cx="62" cy="-14" r="5" />
<path d="M62 -22V-6M54 -14H70" strokeOpacity="0.6" />
</g>
<g fill="rgba(234,241,246,0.7)" fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace" fontSize="8" letterSpacing="1">
<text x="-95" y="33">
0
</text>
<text x="-45" y="33">
50
</text>
<text x="5" y="33">
100
</text>
<text x="55" y="33">
150
</text>
<text x="-26" y="-20">
B 01
</text>
<text x="-12" y="-4" fillOpacity="0.7">
112°
</text>
</g>
</g>
</svg>
</svg>
</span>
);
}
const STYLE_CSS = [
".bdb-btn{appearance:none;-webkit-appearance:none;border:0;padding:0;margin:0;background:transparent;font:inherit;color:inherit;cursor:pointer;position:relative;display:block;touch-action:manipulation;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none;will-change:transform}",
".bdb-btn:focus{outline:none}",
".bdb-btn:focus-visible{outline:2px solid #d9ecf8!important;outline-offset:6px!important;border-radius:" + SHAPE + "!important}",
".bdb-btn:disabled{cursor:not-allowed}",
".bdb-btn:disabled .bdb-body{opacity:.5;filter:saturate(.5)}",
".bdb-btn:disabled .bdb-label{opacity:.62}",
"@keyframes bdb-breathe{0%,100%{opacity:.74}50%{opacity:1}}",
".bdb-caustic{animation:bdb-breathe 9.7s ease-in-out infinite}",
"@media (prefers-reduced-motion:reduce){.bdb-caustic{animation:none}}",
].join("\n");
type Live = { disabled: boolean; magnify: number; h: number };
export function BeadButton({
label = "Commission a room",
size = "md",
tint = "clear",
magnify = 1.35,
ground = "#0a1015",
type = "button",
disabled = false,
className = "",
style,
...rest
}: BeadButtonProps) {
const sz = SIZES[size] ?? SIZES.md;
const tn = TINTS[tint] ?? TINTS.clear;
const mag = clamp(magnify, 1.1, 1.7, 1.35);
const groundHex = HEX.test(ground) ? ground : "#0a1015";
const text = String(label ?? "").slice(0, 60);
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const fid = uid + "d";
const H = sz.h;
const D = Math.round(H * 0.2);
const rootRef = useRef<HTMLDivElement>(null);
const slotRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const bodyRef = useRef<HTMLSpanElement>(null);
const lensRef = useRef<HTMLSpanElement>(null);
const specRef = useRef<HTMLSpanElement>(null);
const labelRef = useRef<HTMLSpanElement>(null);
const causticRef = useRef<HTMLDivElement>(null);
const satRef = useRef<HTMLDivElement>(null);
const neckRef = useRef<HTMLDivElement>(null);
const opts = useRef<Live>({ disabled, magnify: mag, h: H });
const refresh = useRef<() => void>(() => {});
useEffect(() => {
const root = rootRef.current;
const slot = slotRef.current;
const btn = btnRef.current;
const body = bodyRef.current;
const lens = lensRef.current;
const spec = specRef.current;
const lab = labelRef.current;
const caustic = causticRef.current;
const sat = satRef.current;
const neck = neckRef.current;
if (!neck || !root || !slot || !btn || !body || !lens || !spec || !lab || !caustic || !sat) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const s = {
x: 0, y: 0, vx: 0, vy: 0, // shove spring
lx: 0, ly: 0, tlx: 0, tly: 0, // lean (smoothed) and its target
p: 0, pv: 0, pressed: false, // press spring
satT: -1, // satellite clock in seconds, -1 idle
px: 0, py: 0, pt: 0, // last pointer sample for velocity
};
const g = { cx: 0, cy: 0, a: 1, b: 1, k: 1 };
let raf = 0;
let last = 0;
let releaseTimer = 0;
let tap: { x: number; y: number; t: number } | null = null;
const measure = () => {
g.cx = slot.offsetLeft + slot.offsetWidth / 2;
g.cy = slot.offsetTop + slot.offsetHeight / 2;
g.a = Math.max(1, slot.offsetWidth / 2);
g.b = Math.max(1, slot.offsetHeight / 2);
lens.style.width = root.clientWidth + "px";
lens.style.height = root.clientHeight + "px";
};
const ease = (w: number) => w * w * (3 - 2 * w);
const paint = () => {
const H0 = opts.current.h;
const tx = s.x + s.lx * H0 * 0.03;
const ty = s.y + s.ly * H0 * 0.025;
btn.style.transform = "translate(" + f(tx) + "px," + f(ty) + "px)";
const sx = 1 + 0.08 * s.p + 0.025 * Math.abs(s.lx);
const sy = 1 - 0.14 * s.p + 0.02 * Math.abs(s.ly);
body.style.transform = "scale(" + f(sx) + "," + f(sy) + ")";
const m = opts.current.magnify * (1 - 0.04 * s.p);
lens.style.transform =
"rotate(180deg) scale(" + f(m) + ") translate(" + f(-(g.cx + tx + s.lx * H0 * 0.06)) + "px," + f(-(g.cy + ty + s.ly * H0 * 0.05)) + "px)";
spec.style.transform = "rotate(-16deg) translate(" + f(-s.lx * H0 * 0.04) + "px," + f(-s.ly * H0 * 0.035) + "px)";
lab.style.transform = "translate(" + f(s.lx * H0 * 0.02) + "px," + f(s.ly * H0 * 0.015 + s.p * H0 * 0.025) + "px)";
caustic.style.transform = "translate(" + f(tx + H0 * 0.08) + "px," + f(ty + H0 * 0.11) + "px) scale(" + f(1 + 0.05 * s.p) + "," + f(1 - 0.06 * s.p) + ")";
if (s.satT >= 0) {
const u = Math.min(1, s.satT / 0.38);
let e = 0;
if (u < 0.4) e = ease(u / 0.4);
else if (u < 0.55) e = 1;
else e = 1 - ease((u - 0.55) / 0.45);
const th = 0.66;
const nx0 = Math.cos(th) / g.a;
const ny0 = Math.sin(th) / g.b;
const nl = Math.hypot(nx0, ny0) || 1;
const nx = nx0 / nl;
const ny = ny0 / nl;
const dd = Math.round(H0 * 0.2);
const dist = -dd * 0.3 + e * dd * 1.95;
const ox = g.a * Math.cos(th) + nx * dist + tx;
const oy = g.b * Math.sin(th) + ny * dist + ty;
const along = 1 + 1.7 * Math.pow(1 - e, 1.6) * (u < 0.55 ? 1 : 0.6);
const across = 1 / Math.sqrt(along);
const grow = 0.55 + 0.45 * Math.min(1, e * 1.6);
sat.style.opacity = "1";
const tk = u < 0.42 ? 1 - u / 0.42 : u > 0.8 ? (u - 0.8) / 0.2 : 0;
if (tk > 0.03) {
const len = Math.max(2, dist + dd * 0.6);
neck.style.opacity = "1";
neck.style.width = f(len) + "px";
neck.style.height = f(dd * 0.75) + "px";
neck.style.marginTop = f(-dd * 0.375) + "px";
neck.style.transform =
"translate(" + f(g.a * Math.cos(th) + nx * -dd * 0.6 + tx) + "px," + f(g.b * Math.sin(th) + ny * -dd * 0.6 + ty) + "px) rotate(" + f((Math.atan2(ny, nx) * 180) / Math.PI) + "deg) scaleY(" + f(tk) + ")";
} else neck.style.opacity = "0";
sat.style.transform =
"translate(" + f(ox) + "px," + f(oy) + "px) rotate(" + f((Math.atan2(ny, nx) * 180) / Math.PI) + "deg) scale(" + f(along * grow) + "," + f(across * grow) + ")";
} else {
sat.style.opacity = "0";
neck.style.opacity = "0";
}
};
const active = () =>
s.satT >= 0 ||
Math.abs(s.lx - s.tlx) > 0.002 ||
Math.abs(s.ly - s.tly) > 0.002 ||
Math.abs(s.x) > 0.02 ||
Math.abs(s.y) > 0.02 ||
Math.abs(s.vx) > 0.05 ||
Math.abs(s.vy) > 0.05 ||
Math.abs(s.p - (s.pressed ? 1 : 0)) > 0.002 ||
Math.abs(s.pv) > 0.01;
const frame = (now: number) => {
raf = 0;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
const k = 1 - Math.exp(-dt / 0.35);
s.lx += (s.tlx - s.lx) * k;
s.ly += (s.tly - s.ly) * k;
// Shove: a soft spring pulls the bead home (non-wetting).
s.vx += (-150 * s.x - 15 * s.vx) * dt;
s.vy += (-150 * s.y - 15 * s.vy) * dt;
s.x = Math.max(-6, Math.min(6, s.x + s.vx * dt));
s.y = Math.max(-6, Math.min(6, s.y + s.vy * dt));
// Press: underdamped, so it settles with one small overshoot.
s.pv += (260 * ((s.pressed ? 1 : 0) - s.p) - 19 * s.pv) * dt;
s.p += s.pv * dt;
if (s.satT >= 0) {
s.satT += dt;
if (s.satT > 0.38) s.satT = -1;
}
if (active()) {
paint();
raf = requestAnimationFrame(frame);
} else {
last = 0;
s.x = s.y = s.vx = s.vy = 0;
s.lx = s.tlx;
s.ly = s.tly;
s.p = s.pressed ? 1 : 0;
s.pv = 0;
paint();
}
};
const kick = () => {
if (reduced.matches) {
paint();
return;
}
if (!raf) {
last = 0;
raf = requestAnimationFrame(frame);
}
};
const setPressed = (on: boolean, withSatellite: boolean) => {
const next = on && !opts.current.disabled;
s.pressed = next;
if (next && withSatellite && !reduced.matches && s.satT < 0) s.satT = 0;
if (reduced.matches) {
s.p = next ? 1 : 0;
s.pv = 0;
}
kick();
};
const toPlate = (e: PointerEvent) => {
const r = root.getBoundingClientRect();
g.k = r.width / (root.offsetWidth || 1) || 1;
return { x: (e.clientX - r.left) / g.k, y: (e.clientY - r.top) / g.k };
};
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch" || reduced.matches || opts.current.disabled) return;
const p = toPlate(e);
const dx = p.x - (g.cx + s.x);
const dy = p.y - (g.cy + s.y);
const dist = Math.hypot(dx / g.a, dy / g.b);
const prox = dist <= 1 ? 1 : Math.max(0, 1 - (dist - 1) / 2.2);
const sm = prox * prox * (3 - 2 * prox);
s.tlx = Math.max(-1, Math.min(1, dx / (g.a * 1.1))) * sm;
s.tly = Math.max(-1, Math.min(1, dy / (g.b * 1.5))) * sm;
const dt = (e.timeStamp - s.pt) / 1000;
if (dist < 1.15 && dt > 0 && dt < 0.12) {
const vx = (p.x - s.px) / dt;
const vy = (p.y - s.py) / dt;
s.vx = Math.max(-80, Math.min(80, s.vx + Math.max(-40, Math.min(40, vx * 0.05))));
s.vy = Math.max(-80, Math.min(80, s.vy + Math.max(-40, Math.min(40, vy * 0.05))));
}
s.px = p.x;
s.py = p.y;
s.pt = e.timeStamp;
kick();
};
const onLeave = () => {
s.tlx = 0;
s.tly = 0;
kick();
};
const onDown = (e: PointerEvent) => {
if (opts.current.disabled || !(e.target instanceof Node) || !btn.contains(e.target)) return;
if (e.pointerType === "mouse") setPressed(true, true);
else tap = { x: e.clientX, y: e.clientY, t: e.timeStamp };
};
const onUp = (e: PointerEvent) => {
if (e.pointerType === "mouse") {
if (s.pressed) setPressed(false, false);
return;
}
const t0 = tap;
tap = null;
if (!t0 || e.type !== "pointerup") return;
if (Math.hypot(e.clientX - t0.x, e.clientY - t0.y) < 8 && e.timeStamp - t0.t < 300 && e.target instanceof Node && btn.contains(e.target)) {
setPressed(true, true);
window.clearTimeout(releaseTimer);
releaseTimer = window.setTimeout(() => setPressed(false, false), 140);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) setPressed(true, true);
};
const onKeyUp = () => {
if (s.pressed) setPressed(false, false);
};
measure();
paint();
const ro = new ResizeObserver(() => {
measure();
paint();
});
ro.observe(root);
ro.observe(slot);
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
root.addEventListener("pointerdown", onDown);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
btn.addEventListener("blur", onKeyUp);
refresh.current = () => {
measure();
if (opts.current.disabled && s.pressed) s.pressed = false;
kick();
paint();
};
return () => {
refresh.current = () => {};
if (raf) cancelAnimationFrame(raf);
raf = 0;
window.clearTimeout(releaseTimer);
ro.disconnect();
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
root.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
btn.removeEventListener("blur", onKeyUp);
};
}, []);
useEffect(() => {
opts.current = { disabled, magnify: mag, h: H };
refresh.current();
}, [disabled, mag, H, text]);
const padX = Math.round(H * 0.5);
const S = Math.round(H * 0.5);
const rootStyle: CSSProperties = {
padding: Math.round(H * 0.8) + "px min(" + Math.round(H * 1.15 + 20) + "px, 6%)",
backgroundColor: groundHex,
...style,
};
const layer: CSSProperties = { display: "block", position: "absolute", inset: 0 };
return (
<div ref={rootRef} className={"relative isolate inline-flex items-center justify-center overflow-hidden " + className} style={rootStyle}>
<style>{STYLE_CSS}</style>
<Backdrop uid={uid + "p"} ground={groundHex} k={H / 84} />
{/* The lens filter: barrel bend with a chromatic fringe. */}
<svg width="0" height="0" aria-hidden="true" focusable="false" style={{ position: "absolute", pointerEvents: "none" }}>
<filter id={fid} x="-10%" y="-14%" width="120%" height="128%" colorInterpolationFilters="sRGB">
<feImage href={MAP_URI} preserveAspectRatio="none" result="map" />
<feComponentTransfer in="map" result="cm">
<feFuncR type="table" tableValues={BARREL} />
<feFuncG type="table" tableValues={BARREL} />
</feComponentTransfer>
<feDisplacementMap in="SourceGraphic" in2="cm" scale={S * 0.9} xChannelSelector="R" yChannelSelector="G" result="dr" />
<feDisplacementMap in="SourceGraphic" in2="cm" scale={S} xChannelSelector="R" yChannelSelector="G" result="dg" />
<feDisplacementMap in="SourceGraphic" in2="cm" scale={S * 1.1} xChannelSelector="R" yChannelSelector="G" result="db" />
<feColorMatrix in="dr" type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r" />
<feColorMatrix in="dg" type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g" />
<feColorMatrix in="db" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="b" />
<feBlend in="r" in2="g" mode="screen" result="rg" />
<feBlend in="rg" in2="b" mode="screen" />
</filter>
</svg>
<div ref={slotRef} className="relative" style={{ zIndex: 1 }}>
{/* Caustic: the bead focuses the one light into a crescent on the plate, away from the light. */}
<div
ref={causticRef}
className="bdb-caustic"
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
borderRadius: SHAPE,
pointerEvents: "none",
background:
"radial-gradient(ellipse 24% 26% at 80% 84%, rgba(255,255,255,1), rgba(" + tn.caustic + ",0.85) 38%, rgba(" + tn.caustic + ",0.22) 72%, rgba(" + tn.caustic + ",0) 100%)",
filter: "blur(1.5px)",
}}
/>
{/* Satellite droplet: pinches off the lower right on a press and re-merges. */}
<div
ref={satRef}
aria-hidden="true"
style={{
position: "absolute",
left: "50%",
top: "50%",
width: D,
height: D,
marginLeft: -D / 2,
marginTop: -D / 2,
borderRadius: "50%",
opacity: 0,
pointerEvents: "none",
background: [
"radial-gradient(circle at 34% 30%, rgba(255,255,255,0.95) 0, rgba(255,255,255,0.7) 9%, rgba(255,255,255,0) 26%)",
"radial-gradient(circle at 50% 50%, " + tn.sat + " 30%, rgba(3,5,8,0.92) 100%)",
].join(","),
boxShadow: "inset 0 0 0 1px rgba(214,232,245,0.34), inset -1px -2px 2px rgba(217,236,248,0.4), 2px 4px 5px rgba(0,0,0,0.5)",
}}
/>
{/* The pinch neck: a short tapered bridge that snaps as the satellite leaves and forms as it returns. */}
<div
ref={neckRef}
aria-hidden="true"
style={{
position: "absolute",
left: "50%",
top: "50%",
opacity: 0,
pointerEvents: "none",
transformOrigin: "0 50%",
clipPath: "polygon(0 0, 45% 34%, 100% 40%, 100% 60%, 45% 66%, 0 100%)",
background: "linear-gradient(180deg, rgba(255,255,255,0.55), rgba(150,190,215,0.28) 40%, rgba(3,5,8,0.75))",
}}
/>
<button ref={btnRef} type={type} disabled={disabled} className="bdb-btn" {...rest}>
<span
ref={bodyRef}
className="bdb-body"
style={{
...layer,
borderRadius: SHAPE,
overflow: "hidden",
isolation: "isolate",
backgroundColor: "#03060a",
boxShadow: "5px 9px 16px rgba(0,0,0,0.55), 0 0 0 1px rgba(214,232,245,0.07)",
transformOrigin: "50% 50%",
}}
>
{/* The inverted, magnified second copy of the plate, bent at the rim. */}
<span style={{ ...layer, filter: "url(#" + fid + ") brightness(1.32) contrast(1.04)" }}>
<span ref={lensRef} style={{ display: "block", position: "absolute", left: "50%", top: "50%", transformOrigin: "0 0", willChange: "transform", overflow: "hidden" }}>
<Backdrop uid={uid + "l"} ground={groundHex} k={H / 84} />
</span>
</span>
{/* Body tint, deeper where the water is thicker (the rim). */}
<span style={{ ...layer, background: "radial-gradient(ellipse 62% 62% at 46% 42%, " + tn.core + ", " + tn.rim + " 100%)" }} />
{/* A little shade behind the words, so the label keeps its contrast on any plate. */}
<span style={{ ...layer, background: "radial-gradient(ellipse 36% 30% at 50% 50%, rgba(4,7,10,0.38), rgba(4,7,10,0) 100%)" }} />
{/* Focused light inside: the far rim brightens where the light comes out. */}
<span style={{ ...layer, background: "radial-gradient(ellipse 34% 30% at 72% 80%, rgba(217,236,248,0.55), rgba(217,236,248,0) 100%)" }} />
{/* Rim: total internal reflection band, lit crescent, far bounce. */}
<span
style={{
...layer,
borderRadius: SHAPE,
boxShadow: [
"inset 0 0 0 1px rgba(2,4,8,0.8)",
"inset 0 0 4px 2px rgba(2,4,8,0.65)",
"inset 2px 3px 0 -1px rgba(255,255,255,0.95)",
"inset 4px 6px 6px -4px rgba(217,236,248,0.5)",
"inset -3px -5px 5px -4px rgba(217,236,248,0.7)",
].join(","),
}}
/>
<span
style={{
display: "block",
position: "absolute",
left: "30%",
top: "6%",
width: "34%",
height: "11%",
borderRadius: "50%",
transform: "rotate(-4deg)",
background: "linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.34) 40%, rgba(255,255,255,0.12) 80%, rgba(255,255,255,0))",
filter: "blur(1.5px)",
}}
/>
<span
ref={specRef}
style={{
display: "block",
position: "absolute",
left: "13%",
top: "11%",
width: Math.round(H * 0.34),
height: Math.round(H * 0.17),
borderRadius: "50%",
transform: "rotate(-16deg)",
background: "radial-gradient(ellipse at 50% 50%, rgba(255,255,255,1) 0, rgba(255,255,255,0.8) 28%, rgba(255,255,255,0) 68%)",
}}
/>
<span
style={{
display: "block",
position: "absolute",
right: "12%",
bottom: "13%",
width: Math.round(H * 0.1),
height: Math.round(H * 0.07),
borderRadius: "50%",
background: "radial-gradient(ellipse at 50% 50%, rgba(255,255,255,0.55), rgba(255,255,255,0) 70%)",
}}
/>
</span>
<span
ref={labelRef}
className="bdb-label"
style={{
position: "relative",
display: "flex",
height: H,
minWidth: Math.round(H * 1.55),
alignItems: "center",
justifyContent: "center",
padding: "0 " + padX + "px",
whiteSpace: "nowrap",
color: "#eaf1f6",
fontFamily: 'var(--font-instrument, "Instrument Sans"), "Geist", system-ui, sans-serif',
fontSize: sz.font,
fontWeight: 500,
letterSpacing: "-0.012em",
textShadow: "0 1px 2px rgba(2,4,8,0.9), 0 0 12px rgba(2,4,8,0.8)",
}}
>
{text}
</span>
</button>
</div>
</div>
);
}
About this component
Most glassy buttons are a gradient with a blur. This one is a lens. The component draws a small etched plate: a hairline grid, one pool of light, a ruler and a few mono marks. It draws the plate a second time inside the bead, rotated half a turn and magnified about the bead centre, then bends the edge of that copy with a barrel displacement and a faint colour fringe. The result is what a real bead of water does: it shows what is behind it, larger and upside down, while the label stays upright and sharp on top. A thin dark rim, a bright crescent on the lit side and a caustic on the plate away from the light finish the optics. Move the pointer near and the bead leans toward it. Press and it flattens and widens while a satellite droplet pinches off and re-merges in under 400 milliseconds. Sweep across and it is shoved a few pixels, then springs home, because water does not wet this glass. It never dodges the pointer, so the click target is always where you aimed.
Curator’s note
The trick is that the bead owns its backdrop. A second copy of the etched plate sits inside the water, rotated 180 degrees and enlarged about the bead centre, so the inversion is real geometry, not a gradient painted to look wet. Everything else is optics: a dark rim band, one motivated specular, a caustic crescent on the side away from the light. It is CSS and one SVG filter, with a spring loop that exists only while something moves.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Liquid Glass Button
componentsA pill of liquid glass that bends what is behind it: real refraction at the rim, a rim and sheen lit by your pointer, and a gel-like press.
Bell Breath Loader
componentsA progress loader drawn as one translucent jellyfish. Progress is light: it rises through the bell from the rim, lights the ring of organs and lets the tentacles grow. With no value it breathes on a five second cycle, and at 100 it exhales, lifts 24 px and fades.
Reeded Glass Field
backgroundsA pane of fluted glass standing in front of a hidden light. The pointer drags the light behind it, and every rib refracts its own sliver, so the light reads as a comb of luminous shards that slide from rib to rib. A press sends a ripple of curvature through the glass.