Gumball
import { useEffect, useRef, useState, type CSSProperties } from 'react';
const CX = 60;
const CY = 58;
const GLOBE = 50;
const PICKS = ['Aurora', 'Comet', 'Tide', 'Ember', 'Moss', 'Halo'];
const TONES = ['--kit-100', '--kit-300', '--kit-500', '--kit-200', '--kit-400'];
type Ball = { x: number; y: number; vx: number; vy: number; r: number; tone: number };
const rnd = (i: number) => {
const v = Math.sin(i * 91.17) * 43758.5453;
return v - Math.floor(v);
};
function step(balls: Ball[], dt: number) {
for (const b of balls) {
b.vy += 980 * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
}
for (let round = 0; round < 3; round++) {
for (let i = 0; i < balls.length; i++) {
const a = balls[i];
for (let j = i + 1; j < balls.length; j++) {
const b = balls[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const min = a.r + b.r;
const d2 = dx * dx + dy * dy;
if (d2 >= min * min || d2 === 0) continue;
const d = Math.sqrt(d2);
const nx = dx / d;
const ny = dy / d;
const push = (min - d) / 2;
a.x -= nx * push;
a.y -= ny * push;
b.x += nx * push;
b.y += ny * push;
const hit = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (hit < 0) {
const j2 = -(1 + 0.32) * hit * 0.5;
a.vx -= j2 * nx;
a.vy -= j2 * ny;
b.vx += j2 * nx;
b.vy += j2 * ny;
}
}
const dx = a.x - CX;
const dy = a.y - CY;
const d = Math.hypot(dx, dy) || 1;
const max = GLOBE - a.r - 1.5;
if (d > max) {
const nx = dx / d;
const ny = dy / d;
a.x = CX + nx * max;
a.y = CY + ny * max;
const out = a.vx * nx + a.vy * ny;
if (out > 0) {
a.vx -= 1.34 * out * nx;
a.vy -= 1.34 * out * ny;
}
a.vx *= 0.992;
a.vy *= 0.992;
}
}
}
}
// Poured in and settled once, before anything renders, so the first frame is a real pile.
const START: Ball[] = (() => {
const balls: Ball[] = Array.from({ length: 21 }, (_, i) => ({
x: CX + (rnd(i) - 0.5) * 60,
y: CY - 30 + (rnd(i + 30) - 0.5) * 30,
vx: 0,
vy: 0,
r: 8.6 + rnd(i + 60) * 1.1,
tone: i % TONES.length,
}));
for (let i = 0; i < 900; i++) {
step(balls, 1 / 120);
for (const b of balls) {
b.vx *= 0.985;
b.vy *= 0.985;
}
}
return balls;
})();
const css = `
.btn-gumball { flex: none; --body: light-dark(#50535b, #2e3035); --trim: light-dark(#6d7179, #7d8087); --pale: light-dark(#fbfcfd, #f1f2f3); --well: light-dark(#373a40, #121417); display: flex; align-items: center; gap: 12px; padding: 4px; border: 0; border-radius: 14px; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; text-align: left; cursor: pointer; }
.btn-gumball:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.btn-gumball svg { display: block; flex: none; width: 120px; height: 208px; overflow: visible; }
.btn-gumball__cap { fill: var(--trim); }
.btn-gumball__glass { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; }
.btn-gumball__shine { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 62%, transparent); stroke-width: 3.5; stroke-linecap: round; }
.btn-gumball__body { fill: var(--body); }
.btn-gumball__plate { fill: var(--trim); }
.btn-gumball__knob { transform-box: fill-box; transform-origin: center; transition: transform 0.7s cubic-bezier(0.3, 1.2, 0.5, 1); }
.btn-gumball:hover .btn-gumball__knobwrap { transform: rotate(-16deg); }
.btn-gumball__knobwrap { transform-box: fill-box; transform-origin: center; transition: transform 0.2s; }
.btn-gumball__window { fill: var(--well); stroke: color-mix(in oklab, var(--pale) 20%, transparent); }
.btn-gumball__rail { fill: none; stroke: var(--pale); stroke-width: 2; stroke-linecap: round; }
.btn-gumball__out { opacity: 0; }
.btn-gumball__out[data-run='a'] { animation: btn-gumball-run-a 1.25s linear forwards; }
.btn-gumball__out[data-run='b'] { animation: btn-gumball-run-b 1.25s linear forwards; }
@keyframes btn-gumball-run-a {
0% { opacity: 0; transform: translate(60px, 128px); animation-timing-function: ease-in; }
10% { opacity: 0; transform: translate(92px, 150px); }
12% { opacity: 1; transform: translate(92px, 153px); animation-timing-function: ease-in; }
42% { transform: translate(38px, 159px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
50% { transform: translate(28px, 176px); animation-timing-function: ease-in; }
74% { opacity: 1; transform: translate(72px, 181px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
82% { transform: translate(88px, 191px); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
89% { transform: translate(88px, 185px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
96%, 100% { opacity: 1; transform: translate(88px, 191px); }
}
@keyframes btn-gumball-run-b {
0% { opacity: 0; transform: translate(60px, 128px); animation-timing-function: ease-in; }
10% { opacity: 0; transform: translate(92px, 150px); }
12% { opacity: 1; transform: translate(92px, 153px); animation-timing-function: ease-in; }
42% { transform: translate(38px, 159px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
50% { transform: translate(28px, 176px); animation-timing-function: ease-in; }
74% { opacity: 1; transform: translate(72px, 181px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
82% { transform: translate(88px, 191px); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
89% { transform: translate(88px, 185px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
96%, 100% { opacity: 1; transform: translate(88px, 191px); }
}
.btn-gumball__front { display: block; }
.btn-gumball__label { display: block; width: 92px; font-size: 19px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.08; }
.btn-gumball__pick { display: block; min-height: 34px; margin-top: 7px; color: light-dark(#5f626a, #a7abb2); font-size: 12px; line-height: 1.4; }
.btn-gumball__pick strong { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 700; animation: btn-gumball-pick 0.4s cubic-bezier(0.3, 1.7, 0.5, 1) both; }
@keyframes btn-gumball-pick {
from { transform: translateY(8px) scale(0.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn-gumball__knob, .btn-gumball__knobwrap { transition: none; }
.btn-gumball__out[data-run] { animation-duration: 0.01s; }
.btn-gumball__pick strong { animation: none; }
}
`;
export function GumballButton() {
const svg = useRef<SVGSVGElement>(null);
const balls = useRef<Ball[]>(START.map((b) => ({ ...b })));
const turns = useRef(0);
const [turn, setTurn] = useState(0);
const [run, setRun] = useState<{ flip: string; tone: number } | null>(null);
const [pick, setPick] = useState<string | null>(null);
const busy = useRef(false);
const raf = useRef(0);
const timers = useRef<number[]>([]);
useEffect(
() => () => {
cancelAnimationFrame(raf.current);
timers.current.forEach((t) => window.clearTimeout(t));
},
[],
);
const paint = () => {
const nodes = svg.current?.querySelectorAll<SVGGElement>('.btn-gumball__ball');
nodes?.forEach((n, i) => {
n.style.transform = `translate(${balls.current[i].x.toFixed(2)}px,${balls.current[i].y.toFixed(2)}px)`;
});
};
const simulate = () => {
cancelAnimationFrame(raf.current);
let last = performance.now();
let still = 0;
const tick = (t: number) => {
const dt = Math.min(0.033, (t - last) / 1000);
last = t;
for (let i = 0; i < 3; i++) step(balls.current, dt / 3);
paint();
const fast = balls.current.some((b) => Math.hypot(b.vx, b.vy) > 16);
still = fast ? 0 : still + 1;
if (still < 30) raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
};
const rattle = (force: number, seed: number) => {
balls.current.forEach((b, i) => {
b.vx += (rnd(i + seed) - 0.5) * 260 * force;
b.vy -= (0.4 + rnd(i + seed + 50)) * 280 * force;
});
};
const press = () => {
if (busy.current) return;
busy.current = true;
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
turns.current += 1;
setTurn(turns.current);
setRun(null);
setPick(null);
const n = turns.current;
if (!calm) {
rattle(1, n * 7);
simulate();
timers.current.push(window.setTimeout(() => rattle(0.7, n * 13), 240));
}
timers.current.push(
window.setTimeout(() => {
// The lowest ball drops out of the neck; the same node comes back in at the top.
const lowest = balls.current.reduce((a, b) => (b.y > a.y ? b : a));
setRun({ flip: n % 2 ? 'a' : 'b', tone: lowest.tone });
lowest.x = CX + (rnd(n) - 0.5) * 16;
lowest.y = CY - GLOBE + lowest.r + 3;
lowest.vx = 0;
lowest.vy = 0;
lowest.tone = (lowest.tone + 2) % TONES.length;
if (calm) paint();
else simulate();
}, calm ? 0 : 480),
);
timers.current.push(
window.setTimeout(() => {
setPick(PICKS[(n - 1) % PICKS.length]);
busy.current = false;
}, calm ? 60 : 1620),
);
};
return (
<>
<style>
{css}
</style>
<button type="button" className="btn-gumball" aria-label="Surprise me" onClick={press}>
<svg ref={svg} viewBox="0 0 120 208" aria-hidden="true">
<path className="btn-gumball__cap" d="M46 11 C46 3 74 3 74 11 L74 14 L46 14 Z" />
<rect className="btn-gumball__cap" x="56" y="0" width="8" height="5" rx="2" />
<circle className="btn-gumball__glass" cx={CX} cy={CY} r={GLOBE} />
{balls.current.map((b, i) => (
<g key={i} className="btn-gumball__ball" style={{ transform: `translate(${b.x.toFixed(2)}px,${b.y.toFixed(2)}px)` }}>
<circle r={b.r} fill={`var(${TONES[b.tone]})`} />
<circle cx={-b.r * 0.32} cy={-b.r * 0.36} r={b.r * 0.22} fill="var(--pale)" opacity="0.72" />
</g>
))}
<path className="btn-gumball__shine" d="M24 40 C28 28 37 20 48 16" />
<rect className="btn-gumball__cap" x="38" y="104" width="44" height="12" rx="3" />
<rect className="btn-gumball__body" x="10" y="113" width="100" height="94" rx="13" />
<circle className="btn-gumball__plate" cx="60" cy="129" r="13" />
<g className="btn-gumball__knobwrap">
<g className="btn-gumball__knob" style={{ transform: `rotate(${turn * 360}deg)` }}>
<circle cx="60" cy="129" r="13" fill="none" />
<rect x="56" y="118" width="8" height="22" rx="4" fill="var(--pale)" />
</g>
</g>
<rect className="btn-gumball__window" x="18" y="147" width="84" height="53" rx="7" />
<path className="btn-gumball__rail" d="M102 160 L34 166" />
<path className="btn-gumball__rail" d="M18 181 L74 188" />
<path className="btn-gumball__rail" d="M78 192 L78 198 M100 192 L100 198" />
{run && (
<g key={`${run.flip}${turn}`} className="btn-gumball__out" data-run={run.flip}>
<circle r="6.2" fill={`var(${TONES[run.tone]})`} stroke="var(--body)" strokeWidth="1" />
<circle cx="-2" cy="-2.2" r="1.4" fill="var(--pale)" opacity="0.72" />
</g>
)}
</svg>
<span className="btn-gumball__front">
<span className="btn-gumball__label">Surprise me</span>
<span className="btn-gumball__pick" role="status">
{pick ? (
<>
Your pick
<strong>{pick}</strong>
</>
) : (
'Twist for a pick'
)}
</span>
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnGumball(el) on the mounted element instead.
<button type="button" class="btn-gumball" aria-label="Surprise me">
<svg viewBox="0 0 120 208" aria-hidden="true">
<path class="btn-gumball__cap" d="M46 11 C46 3 74 3 74 11 L74 14 L46 14 Z"></path>
<rect class="btn-gumball__cap" x="56" y="0" width="8" height="5" rx="2"></rect>
<circle class="btn-gumball__glass" cx="60" cy="58" r="50"></circle>
<g class="btn-gumball__ball" style="transform:translate(24.01px,72.51px)">
<circle r="9.699" fill="light-dark(#373a40, #f1f2f3)"></circle>
<circle cx="-3.104" cy="-3.492" r="2.134" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(77.04px,31.76px)">
<circle r="9.286" fill="light-dark(#50535b, #c6c8cd)"></circle>
<circle cx="-2.972" cy="-3.343" r="2.043" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(41.91px,23.92px)">
<circle r="8.934" fill="light-dark(#6d7179, #7d8087)"></circle>
<circle cx="-2.859" cy="-3.216" r="1.965" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(96.34px,74.04px)">
<circle r="8.779" fill="light-dark(#42454c, #e5e6e9)"></circle>
<circle cx="-2.809" cy="-3.16" r="1.931" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(52.11px,96.54px)">
<circle r="9.157" fill="light-dark(#5f626a, #a7abb2)"></circle>
<circle cx="-2.93" cy="-3.297" r="2.015" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(63.43px,80.59px)">
<circle r="9.39" fill="light-dark(#373a40, #f1f2f3)"></circle>
<circle cx="-3.005" cy="-3.381" r="2.066" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(99.28px,56.68px)">
<circle r="9.2" fill="light-dark(#50535b, #c6c8cd)"></circle>
<circle cx="-2.944" cy="-3.312" r="2.024" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(27.82px,36.19px)">
<circle r="9.623" fill="light-dark(#6d7179, #7d8087)"></circle>
<circle cx="-3.079" cy="-3.464" r="2.117" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(35.41px,88.02px)">
<circle r="9.69" fill="light-dark(#42454c, #e5e6e9)"></circle>
<circle cx="-3.101" cy="-3.488" r="2.132" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(59.33px,27.83px)">
<circle r="8.836" fill="light-dark(#5f626a, #a7abb2)"></circle>
<circle cx="-2.828" cy="-3.181" r="1.944" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(47.41px,75.02px)">
<circle r="8.882" fill="light-dark(#373a40, #f1f2f3)"></circle>
<circle cx="-2.842" cy="-3.198" r="1.954" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(85.39px,88.09px)">
<circle r="9.132" fill="light-dark(#50535b, #c6c8cd)"></circle>
<circle cx="-2.922" cy="-3.287" r="2.009" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(46.00px,41.79px)">
<circle r="9.672" fill="light-dark(#6d7179, #7d8087)"></circle>
<circle cx="-3.095" cy="-3.482" r="2.128" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(94.13px,38.79px)">
<circle r="9.342" fill="light-dark(#42454c, #e5e6e9)"></circle>
<circle cx="-2.989" cy="-3.363" r="2.055" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(64.27px,44.99px)">
<circle r="9.2" fill="light-dark(#5f626a, #a7abb2)"></circle>
<circle cx="-2.944" cy="-3.312" r="2.024" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(69.89px,96.53px)">
<circle r="8.725" fill="light-dark(#373a40, #f1f2f3)"></circle>
<circle cx="-2.792" cy="-3.141" r="1.919" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(38.50px,59.44px)">
<circle r="9.561" fill="light-dark(#50535b, #c6c8cd)"></circle>
<circle cx="-3.06" cy="-3.442" r="2.103" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(79.31px,70.85px)">
<circle r="9.358" fill="light-dark(#6d7179, #7d8087)"></circle>
<circle cx="-2.994" cy="-3.369" r="2.059" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(60.07px,62.66px)">
<circle r="8.912" fill="light-dark(#42454c, #e5e6e9)"></circle>
<circle cx="-2.852" cy="-3.208" r="1.961" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(81.51px,52.10px)">
<circle r="9.517" fill="light-dark(#5f626a, #a7abb2)"></circle>
<circle cx="-3.046" cy="-3.426" r="2.094" fill="var(--pale)" opacity="0.72"></circle>
</g>
<g class="btn-gumball__ball" style="transform:translate(21.03px,53.73px)">
<circle r="9.296" fill="light-dark(#373a40, #f1f2f3)"></circle>
<circle cx="-2.975" cy="-3.346" r="2.045" fill="var(--pale)" opacity="0.72"></circle>
</g>
<path class="btn-gumball__shine" d="M24 40 C28 28 37 20 48 16"></path>
<rect class="btn-gumball__cap" x="38" y="104" width="44" height="12" rx="3"></rect>
<rect class="btn-gumball__body" x="10" y="113" width="100" height="94" rx="13"></rect>
<circle class="btn-gumball__plate" cx="60" cy="129" r="13"></circle>
<g class="btn-gumball__knobwrap">
<g class="btn-gumball__knob" style="transform:rotate(0deg)">
<circle cx="60" cy="129" r="13" fill="none"></circle>
<rect x="56" y="118" width="8" height="22" rx="4" fill="var(--pale)"></rect>
</g>
</g>
<rect class="btn-gumball__window" x="18" y="147" width="84" height="53" rx="7"></rect>
<path class="btn-gumball__rail" d="M102 160 L34 166"></path>
<path class="btn-gumball__rail" d="M18 181 L74 188"></path>
<path class="btn-gumball__rail" d="M78 192 L78 198 M100 192 L100 198"></path>
<g class="btn-gumball__out">
<circle r="6.2" stroke="var(--body)" stroke-width="1"></circle>
<circle cx="-2" cy="-2.2" r="1.4" fill="var(--pale)" opacity="0.72"></circle>
</g>
</svg>
<span class="btn-gumball__front">
<span class="btn-gumball__label">Surprise me</span>
<span class="btn-gumball__pick" role="status">Twist for a pick</span>
</span>
</button>.btn-gumball { flex: none; --body: light-dark(#50535b, #2e3035); --trim: light-dark(#6d7179, #7d8087); --pale: light-dark(#fbfcfd, #f1f2f3); --well: light-dark(#373a40, #121417); display: flex; align-items: center; gap: 12px; padding: 4px; border: 0; border-radius: 14px; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; text-align: left; cursor: pointer; }
.btn-gumball:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.btn-gumball svg { display: block; flex: none; width: 120px; height: 208px; overflow: visible; }
.btn-gumball__cap { fill: var(--trim); }
.btn-gumball__glass { fill: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 5%, transparent); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; }
.btn-gumball__shine { fill: none; stroke: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 62%, transparent); stroke-width: 3.5; stroke-linecap: round; }
.btn-gumball__body { fill: var(--body); }
.btn-gumball__plate { fill: var(--trim); }
.btn-gumball__knob { transform-box: fill-box; transform-origin: center; transition: transform 0.7s cubic-bezier(0.3, 1.2, 0.5, 1); }
.btn-gumball:hover .btn-gumball__knobwrap { transform: rotate(-16deg); }
.btn-gumball__knobwrap { transform-box: fill-box; transform-origin: center; transition: transform 0.2s; }
.btn-gumball__window { fill: var(--well); stroke: color-mix(in oklab, var(--pale) 20%, transparent); }
.btn-gumball__rail { fill: none; stroke: var(--pale); stroke-width: 2; stroke-linecap: round; }
.btn-gumball__out { opacity: 0; }
.btn-gumball__out[data-run='a'] { animation: btn-gumball-run-a 1.25s linear forwards; }
.btn-gumball__out[data-run='b'] { animation: btn-gumball-run-b 1.25s linear forwards; }
@keyframes btn-gumball-run-a {
0% { opacity: 0; transform: translate(60px, 128px); animation-timing-function: ease-in; }
10% { opacity: 0; transform: translate(92px, 150px); }
12% { opacity: 1; transform: translate(92px, 153px); animation-timing-function: ease-in; }
42% { transform: translate(38px, 159px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
50% { transform: translate(28px, 176px); animation-timing-function: ease-in; }
74% { opacity: 1; transform: translate(72px, 181px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
82% { transform: translate(88px, 191px); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
89% { transform: translate(88px, 185px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
96%, 100% { opacity: 1; transform: translate(88px, 191px); }
}
@keyframes btn-gumball-run-b {
0% { opacity: 0; transform: translate(60px, 128px); animation-timing-function: ease-in; }
10% { opacity: 0; transform: translate(92px, 150px); }
12% { opacity: 1; transform: translate(92px, 153px); animation-timing-function: ease-in; }
42% { transform: translate(38px, 159px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
50% { transform: translate(28px, 176px); animation-timing-function: ease-in; }
74% { opacity: 1; transform: translate(72px, 181px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
82% { transform: translate(88px, 191px); animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
89% { transform: translate(88px, 185px); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
96%, 100% { opacity: 1; transform: translate(88px, 191px); }
}
.btn-gumball__front { display: block; }
.btn-gumball__label { display: block; width: 92px; font-size: 19px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.08; }
.btn-gumball__pick { display: block; min-height: 34px; margin-top: 7px; color: light-dark(#5f626a, #a7abb2); font-size: 12px; line-height: 1.4; }
.btn-gumball__pick strong { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 700; animation: btn-gumball-pick 0.4s cubic-bezier(0.3, 1.7, 0.5, 1) both; }
@keyframes btn-gumball-pick {
from { transform: translateY(8px) scale(0.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn-gumball__knob, .btn-gumball__knobwrap { transition: none; }
.btn-gumball__out[data-run] { animation-duration: 0.01s; }
.btn-gumball__pick strong { animation: none; }
}function initBtnGumball(root) {
var CX = 60;
var CY = 58;
var GLOBE = 50;
var PICKS = ['Aurora', 'Comet', 'Tide', 'Ember', 'Moss', 'Halo'];
var nodes = root.querySelectorAll('.btn-gumball__ball');
var knob = root.querySelector('.btn-gumball__knob');
var out = root.querySelector('.btn-gumball__out');
var outFill = out.querySelector('circle');
var pick = root.querySelector('.btn-gumball__pick');
var TONES = [0, 1, 2, 3, 4].map(function (i) {
return nodes[i].querySelector('circle').getAttribute('fill');
});
var turns = 0;
var busy = false;
var raf = 0;
var timers = [];
function rnd(i) {
var v = Math.sin(i * 91.17) * 43758.5453;
return v - Math.floor(v);
}
function step(list, dt) {
list.forEach(function (b) {
b.vy += 980 * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
});
for (var round = 0; round < 3; round++) {
for (var i = 0; i < list.length; i++) {
var a = list[i];
for (var j = i + 1; j < list.length; j++) {
var b = list[j];
var dx = b.x - a.x;
var dy = b.y - a.y;
var min = a.r + b.r;
var d2 = dx * dx + dy * dy;
if (d2 >= min * min || d2 === 0) continue;
var d = Math.sqrt(d2);
var nx = dx / d;
var ny = dy / d;
var push = (min - d) / 2;
a.x -= nx * push;
a.y -= ny * push;
b.x += nx * push;
b.y += ny * push;
var hit = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (hit < 0) {
var kick = -(1 + 0.32) * hit * 0.5;
a.vx -= kick * nx;
a.vy -= kick * ny;
b.vx += kick * nx;
b.vy += kick * ny;
}
}
var ox = a.x - CX;
var oy = a.y - CY;
var dist = Math.hypot(ox, oy) || 1;
var max = GLOBE - a.r - 1.5;
if (dist > max) {
var wx = ox / dist;
var wy = oy / dist;
a.x = CX + wx * max;
a.y = CY + wy * max;
var off = a.vx * wx + a.vy * wy;
if (off > 0) {
a.vx -= 1.34 * off * wx;
a.vy -= 1.34 * off * wy;
}
a.vx *= 0.992;
a.vy *= 0.992;
}
}
}
}
// Poured in and settled once, the same way the markup's resting pile was made.
var balls = [].map.call(nodes, function (_, i) {
return { x: CX + (rnd(i) - 0.5) * 60, y: CY - 30 + (rnd(i + 30) - 0.5) * 30, vx: 0, vy: 0, r: 8.6 + rnd(i + 60) * 1.1, tone: i % TONES.length };
});
for (var settle = 0; settle < 900; settle++) {
step(balls, 1 / 120);
balls.forEach(function (b) {
b.vx *= 0.985;
b.vy *= 0.985;
});
}
function paint() {
nodes.forEach(function (n, i) {
n.style.transform = 'translate(' + balls[i].x.toFixed(2) + 'px,' + balls[i].y.toFixed(2) + 'px)';
});
}
function simulate() {
cancelAnimationFrame(raf);
var last = performance.now();
var still = 0;
function tick(t) {
var dt = Math.min(0.033, (t - last) / 1000);
last = t;
for (var i = 0; i < 3; i++) step(balls, dt / 3);
paint();
var fast = balls.some(function (b) {
return Math.hypot(b.vx, b.vy) > 16;
});
still = fast ? 0 : still + 1;
if (still < 30) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
}
function rattle(force, seed) {
balls.forEach(function (b, i) {
b.vx += (rnd(i + seed) - 0.5) * 260 * force;
b.vy -= (0.4 + rnd(i + seed + 50)) * 280 * force;
});
}
function drop(n, calm) {
// The lowest ball drops out of the neck; the same node comes back in at the top.
var low = 0;
balls.forEach(function (b, i) {
if (b.y > balls[low].y) low = i;
});
var b = balls[low];
outFill.setAttribute('fill', TONES[b.tone]);
out.setAttribute('data-run', n % 2 ? 'a' : 'b');
b.x = CX + (rnd(n) - 0.5) * 16;
b.y = CY - GLOBE + b.r + 3;
b.vx = 0;
b.vy = 0;
b.tone = (b.tone + 2) % TONES.length;
nodes[low].querySelector('circle').setAttribute('fill', TONES[b.tone]);
if (calm) paint();
else simulate();
}
function onClick() {
if (busy) return;
busy = true;
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var n = ++turns;
knob.style.transform = 'rotate(' + n * 360 + 'deg)';
out.removeAttribute('data-run');
pick.textContent = 'Twist for a pick';
if (!calm) {
rattle(1, n * 7);
simulate();
timers.push(setTimeout(function () {
rattle(0.7, n * 13);
}, 240));
}
timers.push(setTimeout(function () {
drop(n, calm);
}, calm ? 0 : 480));
timers.push(setTimeout(function () {
var strong = document.createElement('strong');
strong.textContent = PICKS[(n - 1) % PICKS.length];
pick.textContent = 'Your pick';
pick.appendChild(strong);
busy = false;
}, calm ? 60 : 1620));
}
root.addEventListener('click', onClick);
return function () {
cancelAnimationFrame(raf);
timers.forEach(clearTimeout);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-gumball').forEach(initBtnGumball);Goal: Create a "Gumball" button.
Visual behavior: A Surprise me button with real physics: the knob turns, the balls rattle around the globe and settle, and one rolls down the marble run into the cup with your pick.
Technique:
- The balls are circles in a small physics step: gravity, collisions with each other and with the round wall. They are poured in and settled once before the first frame, so the resting pile is a real one, and the loop stops as soon as they lie still.
- A press kicks every ball upward twice, then the lowest one leaves the globe and the same node drops back in at the top. A keyframe with a separate easing per stretch rolls a copy down the rails into the cup.
Accessibility: A real button named Surprise me; the drawing is aria-hidden and the pick is read from a role="status" line. Under prefers-reduced-motion the balls do not rattle and the pick appears at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Gumball" button.
Visual behavior: A Surprise me button with real physics: the knob turns, the balls rattle around the globe and settle, and one rolls down the marble run into the cup with your pick.
Technique:
- The balls are circles in a small physics step: gravity, collisions with each other and with the round wall. They are poured in and settled once before the first frame, so the resting pile is a real one, and the loop stops as soon as they lie still.
- A press kicks every ball upward twice, then the lowest one leaves the globe and the same node drops back in at the top. A keyframe with a separate easing per stretch rolls a copy down the rails into the cup.
Accessibility: A real button named Surprise me; the drawing is aria-hidden and the pick is read from a role="status" line. Under prefers-reduced-motion the balls do not rattle and the pick appears at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A Surprise me button with real physics: the knob turns, the balls rattle around the globe and settle, and one rolls down the marble run into the cup with your pick.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring
- Techniques
- raf, keyframes, svg, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real button named Surprise me; the drawing is aria-hidden and the pick is read from a role="status" line. Under prefers-reduced-motion the balls do not rattle and the pick appears at once.