Skip to content

Instantly share code, notes, and snippets.

@alexcatdad
Created March 15, 2026 12:24
Show Gist options
  • Select an option

  • Save alexcatdad/ff43536bd62d847222356c45f84431ce to your computer and use it in GitHub Desktop.

Select an option

Save alexcatdad/ff43536bd62d847222356c45f84431ce to your computer and use it in GitHub Desktop.
Elastic membrane physics simulation — hover to deform container borders with spring-damped button + 60-point membrane collision
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elastic Membrane Physics</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=Instrument+Serif:ital@0;1&family=DM+Mono:wght@300;400;500&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,600;1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0c0c0f;
--surface: #faf6f0;
--grid-line: #e8e0d4;
--grid-major: #d4c9b8;
--border: #2a2520;
--coral: #ff6b6b;
--coral-dark: #e55a5a;
--text-primary: #faf6f0;
--text-muted: #7a7268;
--text-dim: #4a4540;
--accent-teal: #5ee4c0;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
background-size: 256px 256px;
pointer-events: none;
z-index: 100;
}
body::after {
content: '';
position: fixed;
top: 50%;
left: 50%;
width: 900px;
height: 700px;
transform: translate(-50%, -50%);
background: radial-gradient(ellipse, rgba(255,107,107,0.04) 0%, transparent 70%);
pointer-events: none;
}
.page { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 36px; }
.header { text-align: center; max-width: 600px; }
.header h1 { font-family: 'Fraunces', serif; font-weight: 300; font-size: 42px; color: var(--text-primary); letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 12px; }
.header h1 em { font-style: italic; color: var(--coral); font-weight: 600; }
.header p { font-family: 'DM Mono', monospace; font-weight: 300; font-size: 13px; color: var(--text-muted); letter-spacing: 0.04em; }
.header .formula { display: inline-block; margin-top: 10px; padding: 6px 14px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px; font-family: 'DM Mono', monospace; font-size: 12px; color: var(--text-dim); letter-spacing: 0.08em; }
.formula span { color: var(--accent-teal); }
.canvas-wrap { position: relative; }
.canvas-wrap canvas { display: block; border-radius: 4px; }
.params { display: flex; gap: 32px; align-items: center; }
.param { display: flex; align-items: baseline; gap: 8px; font-family: 'DM Mono', monospace; font-size: 11px; }
.param-label { color: var(--text-dim); letter-spacing: 0.08em; text-transform: uppercase; font-size: 9px; }
.param-value { color: var(--text-muted); font-weight: 500; }
.param-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--text-dim); align-self: center; }
.page { animation: fadeUp .8s ease-out both; }
.header { animation: fadeUp .8s ease-out .1s both; }
.canvas-wrap { animation: fadeUp .8s ease-out .25s both; }
.params { animation: fadeUp .8s ease-out .4s both; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="page">
<header class="header">
<h1>Hooke's Law,<br>but make it <em>elastic</em></h1>
<p>hover each shape to deform the membrane</p>
<div class="formula"><span>F</span> = −k·x − d·v &nbsp;×&nbsp; 60 membrane points</div>
</header>
<div class="canvas-wrap">
<canvas id="canvas"></canvas>
</div>
<div class="params">
<div class="param"><span class="param-label">spring</span><span class="param-value">k=0.18 d=0.08</span></div>
<div class="param-dot"></div>
<div class="param"><span class="param-label">membrane</span><span class="param-value">60 points · tension · damped</span></div>
<div class="param-dot"></div>
<div class="param"><span class="param-label">collision</span><span class="param-value">shape-conforming</span></div>
</div>
</div>
<script>
// ============================================================
// BUTTON SPRING
// ============================================================
const SPRING_K = 0.06;
const SPRING_D = 0.52;
// ============================================================
// MEMBRANE SIMULATION
// ============================================================
const MEM_N = 60;
const MEM_K = 0.18;
const MEM_D = 0.55;
const MEM_TENSION = 0.45;
const MEM_SUBSTEPS = 4;
const MEM_GAP = 3;
// ============================================================
// LAYOUT
// ============================================================
const CANVAS_W = 660;
const CANVAS_H = 470;
const C_W = 290;
const C_H = 290;
const C_R = 16;
const C_GAP = 28;
const C_TOP = 24;
const C_BOT = C_TOP + C_H;
const PILL_W = 130;
const PILL_H = 42;
const PILL_R = 21;
const CIRC_R = 28;
const REST_Y = 140;
const HOVER_Y = 345;
const LABEL_Y = 455;
const GRID_SP = 18;
// ============================================================
// COLORS
// ============================================================
const COL_SURFACE = '#faf6f0';
const COL_GRID = '#e8e0d4';
const COL_GRID_MAJ = '#d4c9b8';
const COL_BORDER = '#2a2520';
const COL_BTN = '#ff6b6b';
const COL_SHADOW = '#e55a5a';
const COL_LABEL = '#7a7268';
const BORDER_W = 2.5;
// ============================================================
// HELPERS
// ============================================================
function lerp(a, b, t) { return a + (b - a) * t; }
function clamp(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
// ============================================================
// CANVAS
// ============================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = CANVAS_W * dpr;
canvas.height = CANVAS_H * dpr;
canvas.style.width = CANVAS_W + 'px';
canvas.style.height = CANVAS_H + 'px';
ctx.scale(dpr, dpr);
// ============================================================
// INSTANCES
// ============================================================
const LX = (CANVAS_W - C_W * 2 - C_GAP) / 2;
const RX = LX + C_W + C_GAP;
function createInst(ox, shape, label) {
const membrane = [];
for (let i = 0; i < MEM_N; i++) membrane.push({ y: C_BOT, vy: 0 });
return { ox, shape, label, y: REST_Y, vy: 0, target: REST_Y, hovering: false, membrane };
}
const insts = [
createInst(LX, 'pill', 'PILL'),
createInst(RX, 'circle', 'CIRCLE')
];
// ============================================================
// BUTTON COLLISION — exact surface + soft falloff zone
// ============================================================
const FALLOFF = 25; // px of soft influence outside button edge
function btnSurfaceY(inst, px) {
const cx = inst.ox + C_W / 2;
if (inst.shape === 'pill') {
const halfW = PILL_W / 2;
const bl = cx - halfW, br = cx + halfW;
if (px < bl || px > br) return null;
const botY = inst.y + PILL_H / 2;
if (px >= bl + PILL_R && px <= br - PILL_R) return botY;
if (px < bl + PILL_R) {
const ccx = bl + PILL_R, ccy = botY - PILL_R;
const dx = px - ccx;
return ccy + Math.sqrt(Math.max(0, PILL_R * PILL_R - dx * dx));
}
const ccx = br - PILL_R, ccy = botY - PILL_R;
const dx = px - ccx;
return ccy + Math.sqrt(Math.max(0, PILL_R * PILL_R - dx * dx));
} else {
const dx = px - cx;
if (Math.abs(dx) > CIRC_R) return null;
return inst.y + Math.sqrt(Math.max(0, CIRC_R * CIRC_R - dx * dx));
}
}
// Returns { t: 0-1 influence, surfY } with smooth falloff outside button edges
function btnInfluence(inst, px) {
const cx = inst.ox + C_W / 2;
const sy = btnSurfaceY(inst, px);
if (sy !== null) return { t: 1, surfY: sy };
// Point is outside button — check distance to nearest edge
if (inst.shape === 'pill') {
const halfW = PILL_W / 2;
const bl = cx - halfW, br = cx + halfW;
const dist = px < bl ? bl - px : px - br;
if (dist > FALLOFF) return { t: 0, surfY: null };
const edgePx = clamp(px, bl, br);
const edgeSy = btnSurfaceY(inst, edgePx);
const t = 1 - (dist / FALLOFF);
return { t: t * t, surfY: edgeSy }; // quadratic falloff
} else {
const dx = Math.abs(px - cx);
const dist = dx - CIRC_R;
if (dist > FALLOFF) return { t: 0, surfY: null };
const edgePx = px < cx ? cx - CIRC_R : cx + CIRC_R;
const edgeSy = btnSurfaceY(inst, edgePx);
const t = 1 - (dist / FALLOFF);
return { t: t * t, surfY: edgeSy };
}
}
// ============================================================
// PHYSICS: Button spring
// ============================================================
function updateButton(inst) {
const a = -SPRING_K * (inst.y - inst.target) - SPRING_D * inst.vy;
inst.vy += a;
inst.y += inst.vy;
}
// ============================================================
// PHYSICS: Membrane simulation
// ============================================================
function updateMembrane(inst) {
const pts = inst.membrane;
const n = pts.length;
const sp = C_W / (n - 1);
for (let sub = 0; sub < MEM_SUBSTEPS; sub++) {
for (let i = 1; i < n - 1; i++) {
const p = pts[i];
// Spring back to rest position
const restF = -MEM_K * (p.y - C_BOT);
// Damping
const dampF = -MEM_D * p.vy;
// Tension from neighbors (discrete Laplacian)
const tensF = MEM_TENSION * (pts[i - 1].y + pts[i + 1].y - 2 * p.y);
p.vy += restF + dampF + tensF;
p.y += p.vy;
// Collision with soft falloff around button edges
const px = inst.ox + i * sp;
const inf = btnInfluence(inst, px);
if (inf.t > 0 && inf.surfY !== null) {
// Blend the collision surface from C_BOT (at t=0) to actual surface (at t=1)
const constraintY = lerp(C_BOT, inf.surfY + MEM_GAP, inf.t);
if (p.y < constraintY) {
p.y = constraintY;
if (p.vy < 0) p.vy = 0;
}
}
// Membrane cannot go above container bottom
if (p.y < C_BOT) {
p.y = C_BOT;
p.vy = 0;
}
// Dead zone: kill micro-oscillations
if (Math.abs(p.vy) < 0.05 && Math.abs(p.y - C_BOT) < 0.5) {
p.y = C_BOT;
p.vy = 0;
}
}
// Pin endpoints
pts[0].y = C_BOT; pts[0].vy = 0;
pts[n - 1].y = C_BOT; pts[n - 1].vy = 0;
}
}
// ============================================================
// DRAWING: Grid
// ============================================================
function drawGrid(inst) {
const l = inst.ox, t = C_TOP;
ctx.save();
ctx.beginPath();
ctx.roundRect(l, t, C_W, C_H, C_R);
ctx.clip();
ctx.strokeStyle = COL_GRID;
ctx.lineWidth = 0.5;
for (let x = l + GRID_SP; x < l + C_W; x += GRID_SP) {
ctx.beginPath(); ctx.moveTo(x, t); ctx.lineTo(x, t + C_H); ctx.stroke();
}
for (let y = t + GRID_SP; y < t + C_H; y += GRID_SP) {
ctx.beginPath(); ctx.moveTo(l, y); ctx.lineTo(l + C_W, y); ctx.stroke();
}
ctx.strokeStyle = COL_GRID_MAJ;
ctx.lineWidth = 0.8;
for (let x = l + GRID_SP * 4; x < l + C_W; x += GRID_SP * 4) {
ctx.beginPath(); ctx.moveTo(x, t); ctx.lineTo(x, t + C_H); ctx.stroke();
}
for (let y = t + GRID_SP * 4; y < t + C_H; y += GRID_SP * 4) {
ctx.beginPath(); ctx.moveTo(l, y); ctx.lineTo(l + C_W, y); ctx.stroke();
}
ctx.restore();
}
// ============================================================
// DRAWING: Container (open bottom)
// ============================================================
function drawContainer(inst) {
const l = inst.ox, r = inst.ox + C_W, t = C_TOP, b = C_BOT, rd = C_R;
// Shadow
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.25)';
ctx.shadowBlur = 30;
ctx.shadowOffsetY = 8;
ctx.beginPath();
ctx.roundRect(l, t, C_W, C_H, rd);
ctx.fillStyle = COL_SURFACE;
ctx.fill();
ctx.restore();
// Fill
ctx.beginPath();
ctx.moveTo(l, b); ctx.lineTo(l, t + rd);
ctx.arcTo(l, t, l + rd, t, rd);
ctx.lineTo(r - rd, t);
ctx.arcTo(r, t, r, t + rd, rd);
ctx.lineTo(r, b); ctx.lineTo(l, b);
ctx.closePath();
ctx.fillStyle = COL_SURFACE;
ctx.fill();
drawGrid(inst);
// Stroke (open bottom)
ctx.beginPath();
ctx.moveTo(l, b); ctx.lineTo(l, t + rd);
ctx.arcTo(l, t, l + rd, t, rd);
ctx.lineTo(r - rd, t);
ctx.arcTo(r, t, r, t + rd, rd);
ctx.lineTo(r, b);
ctx.strokeStyle = COL_BORDER;
ctx.lineWidth = BORDER_W;
ctx.stroke();
}
// ============================================================
// DRAWING: Membrane (smooth curve through simulated points)
// ============================================================
function drawMembrane(inst) {
const pts = inst.membrane;
const n = pts.length;
const sp = C_W / (n - 1);
const l = inst.ox, r = inst.ox + C_W;
// Build screen-space points
const screen = [];
for (let i = 0; i < n; i++) {
screen.push({ x: l + i * sp, y: pts[i].y });
}
// Smooth curve helper using quadratic bezier through midpoints
function traceCurve() {
ctx.moveTo(screen[0].x, screen[0].y);
for (let i = 0; i < n - 1; i++) {
const mx = (screen[i].x + screen[i + 1].x) / 2;
const my = (screen[i].y + screen[i + 1].y) / 2;
ctx.quadraticCurveTo(screen[i].x, screen[i].y, mx, my);
}
ctx.lineTo(screen[n - 1].x, screen[n - 1].y);
}
// Fill (white area below container extending into bulge)
ctx.beginPath();
traceCurve();
ctx.lineTo(r, C_BOT);
ctx.lineTo(l, C_BOT);
ctx.closePath();
ctx.fillStyle = COL_SURFACE;
ctx.fill();
// Stroke (the membrane border)
ctx.beginPath();
traceCurve();
ctx.strokeStyle = COL_BORDER;
ctx.lineWidth = BORDER_W;
ctx.stroke();
}
// ============================================================
// DRAWING: Button with squash/stretch
// ============================================================
function drawButton(inst) {
const cx = inst.ox + C_W / 2;
const y = inst.y;
const speed = Math.abs(inst.vy);
const sq = clamp(speed * 0.012, 0, 0.25);
const scY = 1 - sq;
const scX = 1 + sq * 0.6;
ctx.save();
ctx.translate(cx, y);
ctx.scale(scX, scY);
ctx.translate(-cx, -y);
if (inst.shape === 'pill') {
const bx = cx - PILL_W / 2, by = y - PILL_H / 2;
// Shadow
ctx.beginPath();
ctx.roundRect(bx, by + 4, PILL_W, PILL_H, PILL_R);
ctx.fillStyle = COL_SHADOW;
ctx.fill();
// Fill
ctx.beginPath();
ctx.roundRect(bx, by, PILL_W, PILL_H, PILL_R);
ctx.fillStyle = COL_BTN;
ctx.fill();
// Highlight
ctx.beginPath();
ctx.roundRect(bx + 1, by + 1, PILL_W - 2, PILL_H / 2, [PILL_R, PILL_R, 0, 0]);
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
// Text
ctx.fillStyle = '#fff';
ctx.font = '500 13px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('HOVER ME', cx, y + 1);
} else {
// Shadow
ctx.beginPath();
ctx.arc(cx, y + 4, CIRC_R, 0, Math.PI * 2);
ctx.fillStyle = COL_SHADOW;
ctx.fill();
// Fill
ctx.beginPath();
ctx.arc(cx, y, CIRC_R, 0, Math.PI * 2);
ctx.fillStyle = COL_BTN;
ctx.fill();
// Highlight
ctx.save();
ctx.beginPath();
ctx.arc(cx, y, CIRC_R, 0, Math.PI * 2);
ctx.clip();
ctx.beginPath();
ctx.ellipse(cx, y - CIRC_R * 0.35, CIRC_R * 0.7, CIRC_R * 0.45, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fill();
ctx.restore();
// Text
ctx.fillStyle = '#fff';
ctx.font = '500 11px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('HOVER', cx, y + 1);
}
ctx.restore();
}
// ============================================================
// DRAWING: Penetration annotation
// ============================================================
function drawAnnotation(inst) {
const halfH = inst.shape === 'pill' ? PILL_H / 2 : CIRC_R;
const pen = Math.max(0, (inst.y + halfH) - C_BOT);
if (pen < 2) return;
const ax = inst.ox + C_W - 18;
const topY = C_BOT;
const botY = inst.y + halfH;
ctx.save();
ctx.strokeStyle = 'rgba(94,228,192,0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(ax, topY); ctx.lineTo(ax, botY);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(ax - 4, topY); ctx.lineTo(ax + 4, topY);
ctx.moveTo(ax - 4, botY); ctx.lineTo(ax + 4, botY);
ctx.stroke();
ctx.fillStyle = 'rgba(94,228,192,0.7)';
ctx.font = '10px "DM Mono", monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(pen.toFixed(1) + 'px', ax + 8, (topY + botY) / 2);
ctx.restore();
}
// ============================================================
// DRAWING: Label
// ============================================================
function drawLabel(inst) {
const cx = inst.ox + C_W / 2;
ctx.fillStyle = COL_LABEL;
ctx.font = '300 11px "DM Mono", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(inst.label, cx, LABEL_Y);
}
// ============================================================
// MOUSE INPUT
// ============================================================
let mx = -1, my = -1;
function mousePos(e) {
const r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left) * (CANVAS_W / r.width), y: (e.clientY - r.top) * (CANVAS_H / r.height) };
}
function hitBtn(inst, px, py) {
const cx = inst.ox + C_W / 2;
if (inst.shape === 'pill') {
const bx = cx - PILL_W / 2, by = inst.y - PILL_H / 2;
return px >= bx && px <= bx + PILL_W && py >= by && py <= by + PILL_H;
}
const dx = px - cx, dy = py - inst.y;
return dx * dx + dy * dy <= CIRC_R * CIRC_R;
}
function hitContainer(inst, px, py) {
return px >= inst.ox && px <= inst.ox + C_W && py >= C_TOP && py <= C_BOT + 100;
}
canvas.addEventListener('mousemove', (e) => {
const p = mousePos(e);
mx = p.x; my = p.y;
let cur = false;
insts.forEach(inst => {
if (inst.hovering) {
if (!hitContainer(inst, mx, my)) { inst.hovering = false; inst.target = REST_Y; }
} else {
if (hitBtn(inst, mx, my)) { inst.hovering = true; inst.target = HOVER_Y; }
}
if (hitBtn(inst, mx, my)) cur = true;
});
canvas.style.cursor = cur ? 'pointer' : 'default';
});
canvas.addEventListener('mouseleave', () => {
mx = my = -1;
insts.forEach(i => { i.hovering = false; i.target = REST_Y; });
canvas.style.cursor = 'default';
});
// ============================================================
// MAIN LOOP
// ============================================================
function frame() {
insts.forEach(updateButton);
insts.forEach(updateMembrane);
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
insts.forEach(inst => {
drawContainer(inst);
drawMembrane(inst);
drawButton(inst);
drawAnnotation(inst);
drawLabel(inst);
});
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment