Squish switch
The pill is a small soft body. Its two ends are separate weights on springs with jelly between them, and it keeps its volume. Drag it and the end you pull leads while the other trails, so it stretches like taffy. Push it into the end of its tray and it bulges taller. Press it and it dents under your finger. Let go and it slides to the nearest option, lands past it and wobbles back.
With three.js it's a real 3D jelly capsule: soft-lit silicone with a clearcoat shine and light pooling inside, the dent and the wobble bent into its surface on the graphics card. Without three.js it draws the same physics as a light CSS pill, so you can use it anywhere.
Left alone it breathes, plump and inviting. Hover and it swells. Phones that can vibrate feel a tap on press, a tick at each option you drag past and a bump when it lands. It's built on real radio buttons, so forms, arrow keys and screen readers work as usual.
Install with AI
Paste this into Claude Code, Cursor or any AI coding tool.
Add the Squish switch component from https://baked.design/interfaces/squish-switch.md to this project. Follow the install steps on that page and use the code exactly as written.
Install by hand
Copy this file into your project.
// Squish switch: a segmented control that feels like a squishy toy, and that you can grab and drag.
//
// The pill is a small soft body: its two ends are separate masses on springs, joined by the jelly between them,
// and it keeps its volume. Drag it and the end you pull leads while the other trails, so it stretches like taffy;
// squash it against the end of its tray and it bulges taller. Press it and it dents under your finger. Let go and
// it slides to the nearest option, lands past it and wobbles back. Left alone it breathes; hover and it swells.
// Phones that can vibrate feel a tap on press, a tick at each option you drag past, and a bump on landing.
//
// Drawn two ways from the same physics:
// - with three.js: a real 3D jelly capsule, soft-lit silicone with a clearcoat shine, the dent and wobble bent
// into its surface on the graphics card;
// - without: a CSS pill (light, no download), used when three.js isn't passed in, WebGL isn't there, or the
// visitor prefers reduced motion.
// It's built on real radio buttons, so forms, arrow keys and screen readers work as they always do.
//
// import * as THREE from "three";
// import { squish } from "./squish-switch.js";
// squish(document.querySelector(".plans"), { color: "lilac", three: THREE });
// // any element holding <label><input type="radio"><span>Text</span></label>s
//
// Colours: pink, peach, lemon, mint, sky, lilac, neon, or ink (smoky glass, for calm pages).
const COLORS = {
pink: ["#ffd6e8", "#ff9cc8", "#d94f8f", "#6b1840"], peach: ["#ffe2cc", "#ffb489", "#e07636", "#6a2c08"],
lemon: ["#fff7c4", "#ffe676", "#d2aa14", "#5c4a00"], mint: ["#d7f8e6", "#86e6b3", "#2aaf6e", "#0d4a2c"],
sky: ["#d9edff", "#8fc8ff", "#3b88df", "#0f3966"], lilac: ["#ebdfff", "#c1a4ff", "#7f55e3", "#33176e"],
neon: ["#eaffd6", "#a8ff6a", "#39c400", "#1d4d00"], ink: ["#7d7d7b", "#3b3b3a", "#0d0d0d", "#ffffff"],
};
const CSS = `
.squish{ position:relative; display:inline-flex; isolation:isolate; padding:4px; border-radius:999px; user-select:none; touch-action:pan-y; -webkit-tap-highlight-color:transparent;
background:color-mix(in srgb, var(--sq-mid) 16%, transparent); box-shadow:inset 0 1px 3px color-mix(in srgb, var(--sq-deep) 22%, transparent), inset 0 0 0 1px color-mix(in srgb, var(--sq-deep) 18%, transparent); }
.squish label{ position:relative; z-index:1; cursor:pointer; }
.squish.dragging, .squish.dragging label{ cursor:grabbing; }
/* the jelly colours exactly the letters it's over, like a lens sliding across the words: --a and --b are its edges
inside each label, so half a word can be dark and half light mid-drag */
.squish label > span{ display:block; padding:4px 14px; border-radius:999px; color:transparent; -webkit-background-clip:text; background-clip:text;
background-image:linear-gradient(90deg, var(--sq-base) calc(var(--a, 0px) - .5px), var(--sq-text) calc(var(--a, 0px) + .5px), var(--sq-text) calc(var(--b, 0px) - .5px), var(--sq-base) calc(var(--b, 0px) + .5px)); }
.squish input{ position:absolute; opacity:0; pointer-events:none; }
.squish input:focus-visible + span{ outline:2px solid var(--sq-deep); outline-offset:2px; }
.squish-pill{ position:absolute; z-index:0; top:4px; bottom:4px; left:0; border-radius:999px; transform-origin:50% 50%; will-change:transform, width; pointer-events:none;
background:
radial-gradient(110% 80% at 32% 18%, rgba(255,255,255,.8), rgba(255,255,255,0) 48%),
radial-gradient(90% 120% at 68% 115%, color-mix(in srgb, var(--sq-deep) 50%, transparent), transparent 62%),
linear-gradient(180deg, var(--sq-light), var(--sq-mid));
box-shadow: inset 0 2px 5px rgba(255,255,255,.7), inset 0 -5px 10px color-mix(in srgb, var(--sq-deep) 42%, transparent),
0 6px 14px -6px color-mix(in srgb, var(--sq-deep) 80%, transparent), 0 0 calc(var(--sq-glow, .25) * 20px) color-mix(in srgb, var(--sq-mid) 75%, transparent);
opacity:.93; }
.squish-pill::after{ content:""; position:absolute; left:18%; right:34%; top:3px; height:4px; border-radius:999px; background:rgba(255,255,255,.7); filter:blur(1px); transform:translateX(var(--hx, 0px)); }
.squish-drop{ position:absolute; z-index:0; width:6px; height:6px; margin:-3px; border-radius:50%; pointer-events:none; background:radial-gradient(circle at 35% 30%, rgba(255,255,255,.85), var(--sq-mid) 55%, var(--sq-deep)); }
/* the 3D canvas bleeds past the tray, so a bubble-gum bubble and its droplets have room; nothing else uses it */
.squish-gl{ position:absolute; z-index:0; inset:-40px; width:calc(100% + 80px); height:calc(100% + 80px); pointer-events:none; }
`;
let styled = false;
export function squish(group, { color = "pink", three = null } = {}) {
if (!styled) { const s = document.createElement("style"); s.textContent = CSS; document.head.append(s); styled = true; }
group.classList.add("squish");
const c = COLORS[color] || COLORS.pink;
[["--sq-light", c[0]], ["--sq-mid", c[1]], ["--sq-deep", c[2]], ["--sq-text", c[3]]].forEach(([k, v]) => group.style.setProperty(k, v));
const still = matchMedia("(prefers-reduced-motion: reduce)");
const buzz = (ms) => { try { navigator.vibrate?.(ms); } catch {} }; // haptic transients, where the device has them
const labels = () => [...group.querySelectorAll("label")];
const box = (lab) => (lab ? { l: lab.offsetLeft, r: lab.offsetLeft + lab.offsetWidth } : { l: 4, r: 4 });
const chosen = () => group.querySelector("input:checked")?.closest("label");
const tray = () => ({ lo: 4, hi: group.clientWidth - 4, h: group.clientHeight - 8 });
// ---------- the soft body ----------
// L and R are the two ends (with velocities vl, vr). sy is the vertical squash, dent how deep a press is pushed in.
const b0 = box(chosen());
let L = b0.l, R = b0.r, vl = 0, vr = 0, sy = 1, vsy = 0, dent = 0, dentX = 0, glow = 0.25, wob = 0;
let hover = false, press = null, drag = null, landing = false, raf = 0, last = 0, t0 = performance.now(), visible = true;
// the shine: where the light comes from (lx, ly), sprung toward your pointer so the highlights swing and settle
let ptr = null, lx = -0.6, ly = 1, vlx = 0, vly = 0;
// bubble gum: hold the jelly and it inflates (puff 0 to 1) until it pops; pop is its size coming back (0 to 1)
let hold = 0, puff = 0, pop = 1, vpop = 0, popped = 0, popAt = 0, drops = [];
// Where the ends want to be: on the chosen option, or under your finger while you drag (stretching between the
// widths of the options either side, with resistance past the first and last).
const goal = () => {
if (!drag) { const b = box(chosen()); return [b.l, b.r]; }
const ls = labels().map(box), cs = ls.map((b) => (b.l + b.r) / 2);
let cx = drag.x - drag.grab;
const first = cs[0], end = cs[cs.length - 1];
if (cx < first) cx = first - (first - cx) * 0.35; else if (cx > end) cx = end + (cx - end) * 0.35;
let i = 0; while (i < cs.length - 2 && cx > cs[i + 1]) i++;
const t = cs.length > 1 ? Math.max(0, Math.min(1, (cx - cs[i]) / (cs[i + 1] - cs[i]))) : 0;
const w = (ls[i].r - ls[i].l) * (1 - t) + ((ls[i + 1] ?? ls[i]).r - (ls[i + 1] ?? ls[i]).l) * t;
// pushed into a wall, it squashes to no less than three quarters of its width (and bulges by volume)
const { lo, hi } = tray();
let gl = cx - w / 2, gr = cx + w / 2;
if (gr > hi) { gr = hi; gl = Math.min(gl, hi - w * 0.75); }
if (gl < lo) { gl = lo; gr = Math.max(gr, lo + w * 0.75); }
return [gl, gr];
};
const nearest = () => { const cx = (L + R) / 2; let best = null, d = Infinity; labels().forEach((lab) => { const b = box(lab), dd = Math.abs((b.l + b.r) / 2 - cx); if (dd < d) { d = dd; best = lab; } }); return best; };
const step = (now) => {
const dt = Math.min(0.032, (now - last) / 1000 || 0.016); last = now;
let [gl, gr] = goal();
const { lo, hi } = tray();
// the end moving toward its goal in the direction of travel leads (stiffer); the other trails (softer)
const dir = Math.sign((gl + gr) / 2 - (L + R) / 2);
const kLead = drag ? 900 : 520, kTrail = drag ? 260 : 300, damp = drag ? 26 : 13;
const kl = dir < 0 ? kLead : kTrail, kr = dir > 0 ? kLead : kTrail;
// a spring between the ends holds the body together, so a long stretch pulls the trailing end along
const len = R - L, rest = gr - gl, pull = (len - rest) * 160;
vl += (kl * (gl - L) + pull - damp * vl) * dt; vr += (kr * (gr - R) - pull - damp * vr) * dt;
L += vl * dt; R += vr * dt;
// the tray's walls: the jelly can't go through them, so it squashes against them instead
if (L < lo) { L = lo; if (vl < 0) vl *= -0.25; } if (R > hi) { R = hi; if (vr > 0) vr *= -0.25; }
if (R - L < 24) { const m = (L + R) / 2; L = m - 12; R = m + 12; }
// volume: shorter makes it taller, longer makes it thinner (square root, so it stays soft rather than rubbery)
const vol = Math.sqrt(Math.max(0.4, (gr - gl) / Math.max(24, R - L)));
const breathe = !press && !drag && !hover ? Math.sin((now - t0) / 700) * 0.02 : 0;
// never taller than the tray itself (its padding is all the room it has to bulge into)
const { h } = tray(), top = (h + 6) / h;
const gy = Math.min(top, Math.max(0.7, vol * (press ? 0.86 : 1) * (hover && !press ? 1.06 : 1) + breathe));
vsy += (420 * (gy - sy) - 11 * vsy) * dt; sy += vsy * dt; sy = Math.min(top + 0.02, sy);
dent += ((press ? 1 : 0) - dent) * Math.min(1, dt * 14);
glow += ((press || drag ? 1 : hover ? 0.6 : 0.25) - glow) * Math.min(1, dt * 10);
wob = Math.max(0, wob - dt * 1.6);
// the shine follows your pointer on a loose spring, and lags behind the jelly when it moves
const cx = (L + R) / 2, W = Math.max(1, R - L);
const tlx = (ptr ? -0.6 + ((ptr.x - cx) / W) * 2.2 : -0.6) - (vl + vr) * 0.0006, tly = ptr ? 1 - ((ptr.y - group.clientHeight / 2) / group.clientHeight) * 2 : 1;
vlx += (70 * (tlx - lx) - 5 * vlx) * dt; lx += vlx * dt; vly += (70 * (tly - ly) - 5 * vly) * dt; ly += vly * dt;
// bubble gum: holding the jelly without dragging inflates it; past full it pops into droplets and grows back
// measured in real time, so a long press always pops at the same moment however busy the page is
hold = press && drag && !drag.moved && !popped ? (now - press.at) / 1000 : 0;
puff += ((popped ? 0 : Math.max(0, Math.min(1, (hold - 0.3) / 0.9))) - puff) * Math.min(1, dt * 20);
if (puff > 0.95 && !popped) burst();
if (popped && now - popAt > 450) { popped = 0; vpop += 2; }
vpop += (280 * ((popped ? 0 : 1) - pop) - (popped ? 30 : 12) * vpop) * dt; pop = Math.max(0, pop + vpop * dt);
drops = drops.filter((d) => (d.life -= dt) > 0);
drops.forEach((d) => { d.vy += 900 * dt; d.x += d.vx * dt; d.y += d.vy * dt; });
cover();
// landed: a bump you can feel and see
if (landing && Math.abs(vl) + Math.abs(vr) < 60 && Math.abs(gl - L) + Math.abs(gr - R) < 2) { landing = false; buzz(10); vsy += 1.4; wob = 1; }
draw.render(now);
raf = requestAnimationFrame(step);
};
// how much of each label the jelly covers, for its text colour
let base = "";
const cover = () => {
base = base || getComputedStyle(group).color; group.style.setProperty("--sq-base", base);
// the jelly's visible edges: shrunk while it's popped, so the words go back to plain until it grows back
const cx = (L + R) / 2, hw = ((R - L) / 2) * Math.min(1, pop);
labels().forEach((lab) => { const s = lab.querySelector("span"); if (!s) return; const o = lab.offsetLeft;
s.style.setProperty("--a", `${(cx - hw - o).toFixed(1)}px`); s.style.setProperty("--b", `${(cx + hw - o).toFixed(1)}px`); });
};
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { base = ""; cover(); });
const burst = () => {
popped = 1; popAt = performance.now(); puff = 0; vpop = -6; buzz([12, 40, 24]);
const cx = (L + R) / 2, cy = group.clientHeight / 2;
drops = Array.from({ length: 14 }, (_, i) => { const a = (i / 14) * Math.PI * 2 + Math.random() * 0.4, s = 160 + Math.random() * 220;
return { x: cx + Math.cos(a) * (R - L) * 0.3, y: cy + Math.sin(a) * 6, vx: Math.cos(a) * s, vy: Math.sin(a) * s * 0.6 - 120, r: 2 + Math.random() * 3, life: 0.55 + Math.random() * 0.3 }; });
press = null; drag = null; group.classList.remove("dragging");
};
const run = () => {
if (still.matches) { cancelAnimationFrame(raf); raf = 0; [L, R] = goal(); sy = 1; dent = press ? 1 : 0; glow = press || drag ? 1 : hover ? 0.6 : 0.25; pop = 1; cover(); draw.render(performance.now()); return; }
if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(step); }
};
// ---------- drawing ----------
const cssDraw = () => {
const pill = document.createElement("span");
pill.className = "squish-pill"; pill.setAttribute("aria-hidden", "true"); group.prepend(pill);
let dropEls = [];
return {
render() {
pill.style.width = `${R - L}px`;
pill.style.transform = `translateX(${L}px) scale(${pop * (1 + puff * 0.08)}, ${sy * pop * (1 + puff * 0.18)})`;
pill.style.opacity = String(0.93 - puff * 0.3);
pill.style.setProperty("--sq-glow", (glow + puff).toFixed(3)); pill.style.setProperty("--hx", `${(lx + 0.6) * 10}px`);
// droplets from a pop
while (dropEls.length < drops.length) { const d = document.createElement("span"); d.className = "squish-drop"; group.append(d); dropEls.push(d); }
dropEls.forEach((el, i) => { const d = drops[i]; if (!d) { el.remove(); return; } el.style.transform = `translate(${d.x}px, ${d.y}px) scale(${d.r / 3 * Math.min(1, d.life * 3)})`; });
dropEls = dropEls.filter((el) => el.isConnected);
},
resize() {}, destroy() { pill.remove(); dropEls.forEach((el) => el.remove()); },
};
};
const glDraw = (T) => {
const cv = document.createElement("canvas");
cv.className = "squish-gl"; cv.setAttribute("aria-hidden", "true"); group.prepend(cv);
const renderer = new T.WebGLRenderer({ canvas: cv, alpha: true, antialias: true, premultipliedAlpha: true });
renderer.setPixelRatio(Math.min(2, devicePixelRatio || 1));
if ("outputColorSpace" in renderer) renderer.outputColorSpace = T.SRGBColorSpace;
const scene = new T.Scene(), cam = new T.OrthographicCamera(0, 1, 1, -1, -200, 200);
cam.position.z = 100;
// soft studio light: a big sky light, a key from the top left that makes the clearcoat shine, a low fill
scene.add(new T.HemisphereLight(0xffffff, new T.Color(c[2]), 1.6));
const key = new T.DirectionalLight(0xffffff, 2.2); key.position.set(-0.6, 1, 1.2); scene.add(key);
const fill = new T.DirectionalLight(new T.Color(c[0]), 0.8); fill.position.set(0.8, -0.6, 0.6); scene.add(fill);
const u = { uL: { value: 0 }, uR: { value: 1 }, uH: { value: 10 }, uDepth: { value: 0.85 }, uDent: { value: 0 }, uDentX: { value: 0 },
uWob: { value: 0 }, uTime: { value: 0 }, uGlow: { value: 0.25 }, uGlowColor: { value: new T.Color(c[1]) }, uPuff: { value: 0 }, uPop: { value: 1 } };
const mat = new T.MeshPhysicalMaterial({ color: new T.Color(c[1]), roughness: 0.42, metalness: 0, clearcoat: 0.9, clearcoatRoughness: 0.18,
sheen: 0.8, sheenColor: new T.Color(c[0]), sheenRoughness: 0.5, transparent: true, opacity: 0.94, emissive: new T.Color(c[2]), emissiveIntensity: 0.08 });
mat.onBeforeCompile = (sh) => {
Object.assign(sh.uniforms, u);
// the body: a unit sphere mapped onto a capsule from uL to uR, then dented where you press and wobbled
sh.vertexShader = sh.vertexShader
.replace("#include <common>", `#include <common>
uniform float uL, uR, uH, uDepth, uDent, uDentX, uWob, uTime, uPuff, uPop;
vec3 jelly(vec3 p){
float r = uH, a = max(0.0, (uR - uL) * 0.5 - r), cx = (uL + uR) * 0.5;
// inflating like bubble gum: the ends draw in and it blows up into a round, thin bubble
a *= 1.0 - uPuff * 0.85;
r *= 1.0 + uPuff * 1.5;
vec3 q = vec3(cx + sign(p.x) * a + p.x * r, p.y * r, p.z * r * (uDepth + uPuff * 0.15));
float d = exp(-pow((q.x - uDentX) / (r * 1.5), 2.0));
q.y *= 1.0 - uDent * 0.2 * d;
q.z -= uDent * r * 0.35 * d * step(0.0, p.z);
q.y *= 1.0 + uWob * 0.06 * sin((q.x - cx) * 0.09 + uTime * 11.0);
q.y *= 1.0 + uPuff * 0.04 * sin(uTime * 23.0); // straining as it fills
q.xy = vec2(cx, 0.0) + (q.xy - vec2(cx, 0.0)) * uPop;
q.z *= uPop;
return q;
}`)
.replace("#include <beginnormal_vertex>", `vec3 objectNormal = normalize(vec3(normal.x, normal.y, normal.z / uDepth));`)
.replace("#include <begin_vertex>", `vec3 transformed = jelly(position);`);
// light inside the jelly: a glow toward the rim, brighter when pressed or held
sh.fragmentShader = sh.fragmentShader
.replace("#include <common>", `#include <common>\nuniform float uGlow;\nuniform vec3 uGlowColor;`)
.replace("#include <emissivemap_fragment>", `#include <emissivemap_fragment>
totalEmissiveRadiance += uGlowColor * uGlow * 0.55 * pow(1.0 - clamp(dot(normal, vec3(0.0, 0.0, 1.0)), 0.0, 1.0), 1.6);`);
};
const mesh = new T.Mesh(new T.SphereGeometry(1, 96, 48), mat);
mesh.frustumCulled = false; scene.add(mesh);
// droplets from a pop: little jelly beads, flung out and falling
const dropMat = new T.MeshPhysicalMaterial({ color: new T.Color(c[1]), roughness: 0.3, clearcoat: 1, clearcoatRoughness: 0.1, sheen: 0.6, sheenColor: new T.Color(c[0]) });
const beads = new T.InstancedMesh(new T.SphereGeometry(1, 14, 10), dropMat, 16);
beads.frustumCulled = false; beads.count = 0; scene.add(beads);
const M = new T.Matrix4(), gum = new T.Color(c[1]), thin = new T.Color(c[0]);
let W = 1, H = 1;
const resize = () => {
W = group.clientWidth; H = group.clientHeight;
// the canvas bleeds 40px past the tray on every side, so the camera does too
renderer.setSize(W + 80, H + 80, false);
cam.left = -40; cam.right = W + 40; cam.top = H / 2 + 40; cam.bottom = -H / 2 - 40; cam.updateProjectionMatrix();
};
resize();
return {
render(now) {
const h = (tray().h / 2) * sy;
u.uL.value = L; u.uR.value = R; u.uH.value = h; u.uDent.value = dent; u.uDentX.value = dentX;
u.uWob.value = wob; u.uTime.value = (now - t0) / 1000; u.uGlow.value = glow + puff * 0.8;
u.uPuff.value = puff; u.uPop.value = pop;
// a stretched bubble gets thin: lighter and more see-through
mat.opacity = 0.94 - puff * 0.45; mat.color.copy(gum).lerp(thin, puff * 0.7);
key.position.set(lx, ly, 1.2); fill.position.set(-lx * 0.9 + 0.2, -ly * 0.6, 0.6);
beads.count = drops.length;
drops.forEach((d, i) => { const s = d.r * Math.min(1, d.life * 3); M.makeScale(s, s, s); M.setPosition(d.x, group.clientHeight / 2 - d.y, 4); beads.setMatrixAt(i, M); });
beads.instanceMatrix.needsUpdate = true;
renderer.render(scene, cam);
},
resize, destroy() { renderer.dispose(); mesh.geometry.dispose(); mat.dispose(); beads.geometry.dispose(); dropMat.dispose(); cv.remove(); },
};
};
const webgl = () => { try { const t = document.createElement("canvas"); return !!(t.getContext("webgl2") || t.getContext("webgl")); } catch { return false; } };
let draw = three && webgl() && !still.matches ? (() => { try { return glDraw(three); } catch { return null; } })() : null;
if (!draw) draw = cssDraw();
// ---------- hands ----------
const px = (e) => e.clientX - group.getBoundingClientRect().left;
let skipClick = false;
const pt = (e) => { const r = group.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
group.addEventListener("pointerenter", (e) => { hover = true; ptr = pt(e); run(); });
group.addEventListener("pointerleave", () => { hover = false; ptr = null; run(); });
group.addEventListener("pointerdown", (e) => {
const x = px(e);
press = { x, at: performance.now() }; dentX = x; buzz(6);
// grabbing the pill itself starts a drag; pressing another option just selects it on release, as normal
if (x >= L - 6 && x <= R + 6) { drag = { x, grab: x - (L + R) / 2, moved: false, last: nearest() }; group.setPointerCapture(e.pointerId); }
run();
});
group.addEventListener("pointermove", (e) => {
ptr = pt(e);
if (press) dentX = px(e);
if (!drag) return;
const x = px(e);
if (!drag.moved && Math.abs(x - drag.x) < 4) return;
drag.moved = true; drag.x = x; group.classList.add("dragging"); press = null;
const n = nearest(); if (n !== drag.last) { drag.last = n; buzz(4); } // a tick at each option you pass
run();
});
const letGo = () => {
if (drag?.moved) {
const n = nearest(), inp = n?.querySelector("input");
if (inp && !inp.checked) { inp.checked = true; inp.dispatchEvent(new Event("input", { bubbles: true })); inp.dispatchEvent(new Event("change", { bubbles: true })); }
skipClick = true; setTimeout(() => (skipClick = false), 0);
}
if (drag || press) landing = true;
drag = null; press = null; group.classList.remove("dragging"); run();
};
group.addEventListener("pointerup", letGo); group.addEventListener("pointercancel", letGo);
// the click that ends a drag shouldn't also select whatever label happens to be under the finger
group.addEventListener("click", (e) => { if (skipClick) { e.preventDefault(); e.stopPropagation(); } }, true);
group.addEventListener("change", () => { landing = true; run(); });
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) run(); else { cancelAnimationFrame(raf); raf = 0; } });
io.observe(group);
const ro = new ResizeObserver(() => { draw.resize(); [L, R] = goal(); draw.render(performance.now()); }); ro.observe(group);
draw.render(performance.now()); run();
return { destroy() { ro.disconnect(); io.disconnect(); cancelAnimationFrame(raf); draw.destroy(); group.classList.remove("squish"); } };
}
// For a standalone demo: builds the radios for you.
export function mount(el, { options = ["Day", "Week", "Month"], name = "squish-" + Math.random().toString(36).slice(2, 7), value = 0, label = "Choose one", color = "pink", three = null } = {}) {
const g = document.createElement("div");
g.setAttribute("role", "radiogroup"); g.setAttribute("aria-label", label);
options.forEach((o, i) => {
const l = document.createElement("label"), inp = document.createElement("input"), s = document.createElement("span");
inp.type = "radio"; inp.name = name; inp.value = o; inp.checked = i === value; s.textContent = o;
l.append(inp, s); g.append(l);
});
el.append(g);
return squish(g, { color, three });
}
Use
// In your HTML, real radio buttons:
// <div class="plans" role="radiogroup" aria-label="Plan">
// <label><input type="radio" name="plan" value="basic" checked><span>Basic</span></label>
// <label><input type="radio" name="plan" value="pro"><span>Pro</span></label>
// </div>
import * as THREE from "three";
import { squish } from "./squish-switch.js";
squish(document.querySelector(".plans"), { color: "lilac", three: THREE });
// leave out three for the light CSS versionNotes
- Colours: pink, peach, lemon, mint, sky, lilac, neon, or ink (smoky glass, for calm pages; it's the Basic/Pro switch in our calculator, in its CSS version).
- Pass three.js in as three (any recent version) for the 3D jelly. Without it, or without WebGL, it uses the CSS pill with the same physics.
- Drag the pill, or press any option. Dragging past the first or last option meets resistance; the jelly never leaves its tray.
- Haptics use the browser's vibration, so they work on most Android phones; elsewhere it's simply silent.
- Respects reduced motion: the CSS pill, no breathing or springs, only the light changes.
- It pauses when it's off screen. destroy() removes it.