Gumball

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/button-gumball
Source on GitHub

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.

More buttons

See all
Honk
Ransom note
Scribble
Shy