Rube Goldberg form: a submit button you have to earn

A ball rolls through a little machine above your form, waits at every unanswered question, and lifts a cage off the button when it arrives.

Most forms make the submit button grey until every field is filled in, and most people never notice. This one turns the same rule into a small machine: a ball rolls along rails above each question and waits at a gate. Answer the question and the gate swings open. When the ball finally lands on the button, a cage lifts and the button unlocks.

It is a joke with a real job. The ball shows people exactly which question is still holding them up, which a grey button never does.

How often should we write?
Live demo. It runs on exactly the code further down this page.

How it works

The form itself is ordinary HTML: a text input, a group of radio buttons and a select. Everything mechanical is one SVG laid over the form and drawn from measurements of the real fields, so the machine always fits whatever form you put under it.

  1. Measure. The script reads where each question sits with getBoundingClientRect() and places a slightly sloped rail above it. Rails alternate left to right and right to left, joined by pipes at the sides.
  2. Build one path. All the rails and drops become a single list of points. The ball only ever needs one number: how far along that path it has travelled.
  3. Roll. Every animation frame adds gravity along the slope, subtracts a little friction and moves the ball forward. Vertical drops accelerate quickly, and the ball loses half its speed when it lands on the next rail, which gives it a believable bounce.
  4. Gates. Each question owns a stopping point on the path. If its answer is not valid yet, the ball stops there and the question gets a small blinking dot. A valid answer opens the gate: a star wheel turns, a sprung trapdoor swings up, or a bolt slides out.
  5. Unlock. At the end of the path the cage over the button rises and the button loses its aria-disabled state. If someone empties a field the ball has already passed, the gate closes again and the ball starts over.

The code

Three files, no libraries. Copy them into a page in this order and it runs. The validity rule lives in one small function, ok(i), so changing what counts as a finished answer takes one line.

HTML
<form class="rgf" data-lab="rube-goldberg" novalidate>
  <div class="rgf-step">
    <label class="rgf-q" for="rgf-name">Your name</label>
    <input id="rgf-name" type="text" placeholder="At least 3 letters" autocomplete="off">
  </div>
  <fieldset class="rgf-step">
    <legend class="rgf-q">How often should we write?</legend>
    <div class="rgf-chips">
      <label><input type="radio" name="often" value="weekly"><span>Weekly</span></label>
      <label><input type="radio" name="often" value="monthly"><span>Monthly</span></label>
      <label><input type="radio" name="often" value="rarely"><span>Rarely</span></label>
    </div>
  </fieldset>
  <div class="rgf-step">
    <label class="rgf-q" for="rgf-topic">Pick a topic</label>
    <select id="rgf-topic">
      <option value="">Choose one</option>
      <option>Design</option>
      <option>Marketing</option>
      <option>Code</option>
    </select>
  </div>
  <div class="rgf-end">
    <button class="rgf-btn" type="submit">Send</button>
    <span class="rgf-hint" aria-live="polite"></span>
  </div>
</form>
CSS
.rgf {
  --line: #a7aea3;
  --pipe: #e8ebe4;
  --ink: #101a14;
  --accent: #133621;
  --gold: #f4d96c;
  position: relative;
  width: min(100%, 560px);
  margin: 0;
  padding: 84px 56px 30px;
  font-family: system-ui, sans-serif;
}
.rgf-step { position: relative; margin: 0 0 60px; padding: 0; border: 0; min-width: 0; }
.rgf-q { display: block; padding: 0; margin: 0 0 10px; font: 600 16px/1.3 system-ui, sans-serif; color: var(--ink); }
.rgf input[type="text"],
.rgf select {
  box-sizing: border-box;
  width: 100%;
  height: 48px;
  padding: 0 14px;
  border: 1px solid #c9cec4;
  border-radius: 10px;
  background: #fff;
  font: 400 16px/1 system-ui, sans-serif;
  color: var(--ink);
  transition: border-color .2s, box-shadow .2s;
}
.rgf-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.rgf-chips label { position: relative; cursor: pointer; }
.rgf-chips input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.rgf-chips span {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid #c9cec4;
  border-radius: 999px;
  background: #fff;
  font-size: 15px;
}
.rgf-chips input:checked + span { background: var(--accent); border-color: var(--accent); color: var(--gold); }
.rgf-chips input:focus-visible + span,
.rgf input:focus-visible,
.rgf select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The question the ball is waiting at. */
.rgf-step.is-waiting input[type="text"],
.rgf-step.is-waiting select,
.rgf-step.is-waiting .rgf-chips span { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(19, 54, 33, .08); }
.rgf-step.is-waiting .rgf-q::after {
  content: "";
  display: inline-block;
  width: 9px;
  height: 9px;
  margin-left: 8px;
  border-radius: 50%;
  background: var(--accent);
  animation: rgf-blink 1.1s ease-in-out infinite;
}
@keyframes rgf-blink { 50% { opacity: .4; transform: scale(.75); } }
.rgf-end { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 18px; margin-top: 84px; }
.rgf-btn {
  height: 48px;
  padding: 0 28px;
  border: 0;
  background: var(--accent);
  color: var(--gold);
  font: 600 15px/1 system-ui, sans-serif;
  cursor: pointer;
}
.rgf-btn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
.rgf.is-unlocked .rgf-btn { animation: rgf-pop .6s cubic-bezier(.34, 1.56, .64, 1) .3s both; }
@keyframes rgf-pop { 0% { transform: scale(.94); } 60% { transform: scale(1.05); } 100% { transform: none; } }
.rgf-hint { font-size: 14px; color: #46534a; }

/* The machine drawing */
.rgf svg { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 2; }
.rgf .m-line, .rgf .m-spring, .rgf .m-thin { fill: none; stroke: var(--line); stroke-width: 1.2; stroke-linecap: round; stroke-linejoin: round; }
.rgf .m-thin { stroke-width: 1; }
.rgf .m-rail { stroke: #8d958a; stroke-width: 1.6; stroke-linecap: round; }
.rgf .m-pipe { fill: var(--pipe); stroke: var(--line); stroke-width: 1.2; }
.rgf .m-valve, .rgf .m-guide { fill: #f7f8f5; stroke: var(--line); stroke-width: 1.2; }
.rgf .m-cog { fill: #f7f8f5; stroke: #8d958a; stroke-width: 1.2; stroke-linejoin: round; }
.rgf .m-hub { fill: #8d958a; }
.rgf .m-wheel { transition: transform .7s cubic-bezier(.34, 1.56, .64, 1); }
.rgf .m-flap { stroke: #5e675b; stroke-width: 2.5; stroke-linecap: round; transition: transform .56s cubic-bezier(.34, 1.56, .64, 1); }
.rgf .m-bolt { fill: #5e675b; transition: transform .42s cubic-bezier(.34, 1.56, .64, 1); }
.rgf .m-weight { fill: #c9cec4; stroke: #8d958a; stroke-width: 1; }
.rgf .m-cage { transition: transform .9s cubic-bezier(.22, 1, .36, 1); }
.rgf .m-cage-frame { fill: rgba(255, 255, 255, .35); stroke: #8d958a; stroke-width: 1.4; }
.rgf .m-ball circle { fill: var(--accent); }
.rgf .m-ball .m-shine { fill: var(--gold); }
.rgf .m-ripple { fill: none; stroke: var(--accent); stroke-width: 1.5; animation: rgf-ripple .8s ease-out forwards; }
@keyframes rgf-ripple { from { transform: scale(.6); opacity: .9; } to { transform: scale(3); opacity: 0; } }
@media (max-width: 520px) { .rgf { padding: 80px 40px 24px; } }
JavaScript
(function () {
  var form = document.querySelector('[data-lab="rube-goldberg"]');
  if (!form) return;
  var steps = [].slice.call(form.querySelectorAll('.rgf-step'));
  var endRow = form.querySelector('.rgf-end');
  var btn = form.querySelector('.rgf-btn');
  var hint = form.querySelector('.rgf-hint');

  // Is the answer in step i good enough to open its gate?
  function ok(i) {
    var s = steps[i];
    var text = s.querySelector('input[type="text"], input[type="email"]');
    if (text) return text.value.trim().length >= 3;
    if (s.querySelector('input[type="radio"]')) return !!s.querySelector('input:checked');
    var sel = s.querySelector('select');
    return !!(sel && sel.value);
  }
  function allOk() { return steps.every(function (_, i) { return ok(i); }); }

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var machine = null;

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    if (!allOk()) { hint.textContent = 'Answer every question first.'; return; }
    if (machine && !machine.unlocked()) { hint.textContent = 'Nearly there, the ball is still rolling.'; return; }
    hint.textContent = 'Sent! This demo does not store anything.';
  });
  if (reduce) return;   // without motion it is simply a normal form

  machine = (function () {
    var NS = 'http://www.w3.org/2000/svg';
    var R = 7, P = R + 5, G = 1700;   // ball radius, pipe half width, gravity in px per second squared
    function el(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }
    function spring(x1, y1, x2, y2, coils, w) {
      var dx = x2 - x1, dy = y2 - y1, len = Math.hypot(dx, dy), ux = dx / len, uy = dy / len, pts = [[x1, y1]];
      var a = len * 0.15, b = len * 0.85;
      pts.push([x1 + ux * a, y1 + uy * a]);
      for (var i = 1; i < coils * 2; i++) {
        var t = a + (b - a) * i / (coils * 2), side = i % 2 ? w : -w;
        pts.push([x1 + ux * t - uy * side, y1 + uy * t + ux * side]);
      }
      pts.push([x1 + ux * b, y1 + uy * b], [x2, y2]);
      return 'M' + pts.map(function (p) { return p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join(' L');
    }

    var svg = el('svg', { 'aria-hidden': 'true', focusable: 'false' });
    form.insertBefore(svg, form.firstChild);
    btn.setAttribute('aria-disabled', 'true');

    var pts = [], cum = [], total = 0, gates = [], parts = [], ball, cage, rope, ropeY = 0, cageLift = 0;
    var open = steps.map(function (_, i) { return ok(i); });
    var s = 0, v = 0, raf = 0, last = 0, done = false, started = false, wheelRot = 0;

    function rel(n) {
      var a = n.getBoundingClientRect(), b = form.getBoundingClientRect();
      return { t: a.top - b.top, l: a.left - b.left, w: a.width, h: a.height };
    }
    function stage() { for (var i = 0; i < gates.length; i++) if (s < gates[i] - 0.5) return i; return gates.length; }
    function pipe(x, y1, y2, parent) {
      el('rect', { x: x - P, y: y1 - P, width: 2 * P, height: Math.max(2 * P, y2 - y1 + 2 * P), rx: P, 'class': 'm-pipe' }, parent);
      if (y2 - y1 > 40) el('rect', { x: x - P - 4, y: (y1 + y2) / 2 - 3, width: 2 * P + 8, height: 6, rx: 3, 'class': 'm-valve' }, parent);
    }

    // Measure the form and draw the whole machine around it.
    function layout() {
      var keep = gates.length ? stage() : 0, W = form.clientWidth, H = form.clientHeight;
      svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
      svg.setAttribute('width', W);
      svg.setAttribute('height', H);
      while (svg.firstChild) svg.removeChild(svg.firstChild);
      var xL = P + 10, xR = W - P - 10, b = rel(btn);
      var rails = steps.concat([endRow]).map(function (n, i) {
        var r = rel(n), dir = i % 2 ? -1 : 1, y = r.t - 22;   // rails zig zag left and right
        return { y0: y - 10, y1: y, dir: dir, x0: dir > 0 ? xL : xR, x1: dir > 0 ? xR : xL, box: r };
      });
      var lastRail = rails[rails.length - 1];
      lastRail.x1 = b.l + b.w / 2;
      var cageTop = b.t - 10;

      // The path the centre of the ball follows.
      pts = [[xL, 10]];
      rails.forEach(function (r) { pts.push([r.x0, r.y0 - R], [r.x1, r.y1 - R]); });
      pts.push([lastRail.x1, cageTop - R]);
      cum = [0]; total = 0;
      for (var i = 1; i < pts.length; i++) {
        total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
        cum.push(total);
      }

      var back = el('g', {}, svg), front = el('g', {}, svg);
      el('path', { d: 'M' + (xL - 16) + ' 0 L' + (xL - P) + ' 14 M' + (xL + 16) + ' 0 L' + (xL + P) + ' 14', 'class': 'm-line' }, back);
      pipe(xL, 16, rails[0].y0 - R - 2, back);
      for (var k = 1; k < rails.length; k++) pipe(rails[k].x0, rails[k - 1].y1 - R, rails[k].y0 - R, back);
      rails.forEach(function (r, i) {
        var a = r.x0 - r.dir * (P + 2), z = i < rails.length - 1 ? r.x1 - r.dir * (P + 2) : r.x1 + r.dir * (P + 4);
        el('line', { x1: a, y1: r.y0, x2: z, y2: r.y1, 'class': 'm-rail' }, front);
        el('line', { x1: a, y1: r.y0 - 6, x2: a, y2: r.y0, 'class': 'm-rail' }, front);
      });
      // The first question hangs from its rail on two springs.
      var bx = rails[0].box;
      el('path', { d: spring(bx.l + 24, rails[0].y0 + 1, bx.l + 24, bx.t + 2, 4, 3), 'class': 'm-spring' }, back);
      el('path', { d: spring(bx.l + bx.w - 24, rails[0].y1 + 2, bx.l + bx.w - 24, bx.t + 2, 4, 3), 'class': 'm-spring' }, back);

      // One mechanism per question: a star wheel, a sprung trapdoor, then a sliding bolt (repeating).
      gates = []; parts = [];
      steps.forEach(function (_, k2) {
        var r = rails[k2], ci = 2 + k2 * 2, kind = ['wheel', 'flap', 'bolt'][k2 % 3];
        if (kind === 'wheel') {
          var wx = r.x1, wy = r.y1 - R, wr = 13, d = '';
          gates.push(cum[ci] - (wr + R + 1));
          var wheel = el('g', { 'class': 'm-wheel' }, front);
          for (var q = 0; q < 16; q++) {
            var an = q * Math.PI / 8, rr = q % 2 ? wr - 4 : wr;
            d += (q ? 'L' : 'M') + (wx + Math.cos(an) * rr).toFixed(1) + ' ' + (wy + Math.sin(an) * rr).toFixed(1);
          }
          el('path', { d: d + 'Z', 'class': 'm-cog' }, wheel);
          for (var q2 = 0; q2 < 4; q2++) {
            var a2 = q2 * Math.PI / 4, c = Math.cos(a2) * (wr - 5), sn = Math.sin(a2) * (wr - 5);
            el('line', { x1: wx - c, y1: wy - sn, x2: wx + c, y2: wy + sn, 'class': 'm-thin' }, wheel);
          }
          el('circle', { cx: wx, cy: wy, r: 2.5, 'class': 'm-hub' }, wheel);
          el('line', { x1: wx, y1: wy - wr - 10, x2: wx, y2: wy - wr, 'class': 'm-line' }, front);
          wheel.style.transformOrigin = wx + 'px ' + wy + 'px';
          wheel.style.transform = 'rotate(' + wheelRot + 'deg)';
          parts.push({ kind: kind, node: wheel, x: wx, y: wy });
        } else if (kind === 'flap') {
          var fx = r.x1 - r.dir * (P + 4), ft = r.y1 - 2 * R - 10;
          gates.push(cum[ci] - (P + 4 + R + 1));
          el('path', { d: spring(fx - r.dir * 34, r.y1 - 2 * R - 22, fx, ft, 5, 3), 'class': 'm-spring' }, back);
          el('line', { x1: fx - r.dir * 34, y1: r.y1 - 2 * R - 26, x2: fx - r.dir * 34, y2: r.y1 - 2 * R - 18, 'class': 'm-line' }, back);
          var flap = el('line', { x1: fx, y1: ft, x2: fx, y2: r.y1 - 1, 'class': 'm-flap' }, front);
          el('circle', { cx: fx, cy: ft, r: 2.5, 'class': 'm-hub' }, front);
          flap.style.transformOrigin = fx + 'px ' + ft + 'px';
          parts.push({ kind: kind, node: flap, dir: r.dir, x: fx, y: r.y1 - R });
        } else {
          var px = r.x1 - r.dir * (P + 4);
          gates.push(cum[ci] - (P + 4 + R + 1));
          el('rect', { x: px - 5, y: r.y1 - 2 * R - 22, width: 10, height: 8, rx: 2, 'class': 'm-guide' }, back);
          var bolt = el('rect', { x: px - 2, y: r.y1 - 2 * R - 12, width: 4, height: 2 * R + 11, rx: 2, 'class': 'm-bolt' }, front);
          parts.push({ kind: kind, node: bolt, x: px, y: r.y1 - R });
        }
      });

      // The cage over the button hangs from a rope and pulley.
      var cw = b.w + 16, ch = b.h + 14, cx0 = b.l - 8, cy0 = cageTop, pxu = cx0 - 22, pyu = cageTop - 30;
      el('circle', { cx: pxu, cy: pyu, r: 7, 'class': 'm-cog' }, back);
      el('circle', { cx: pxu, cy: pyu, r: 2, 'class': 'm-hub' }, back);
      el('line', { x1: pxu, y1: pyu - 7, x2: pxu, y2: pyu - 22, 'class': 'm-line' }, back);
      el('line', { x1: pxu - 7, y1: pyu, x2: pxu - 7, y2: pyu + 26, 'class': 'm-line' }, back);
      el('circle', { cx: pxu - 7, cy: pyu + 30, r: 4, 'class': 'm-weight' }, back);
      rope = el('line', { x1: pxu + 7, y1: pyu, x2: cx0 + 10, y2: cy0, 'class': 'm-line' }, back);
      ropeY = cy0;
      cage = el('g', { 'class': 'm-cage' }, front);
      el('path', { d: 'M' + cx0 + ' ' + (cy0 + ch) + ' V' + (cy0 + 8) + ' Q' + cx0 + ' ' + cy0 + ' ' + (cx0 + 8) + ' ' + cy0 + ' H' + (cx0 + cw - 8) +
        ' Q' + (cx0 + cw) + ' ' + cy0 + ' ' + (cx0 + cw) + ' ' + (cy0 + 8) + ' V' + (cy0 + ch), 'class': 'm-cage-frame' }, cage);
      for (var bar = cx0 + 12; bar < cx0 + cw - 6; bar += 12) el('line', { x1: bar, y1: cy0 + 1, x2: bar, y2: cy0 + ch, 'class': 'm-thin' }, cage);
      el('line', { x1: cx0, y1: cy0 + ch / 2, x2: cx0 + cw, y2: cy0 + ch / 2, 'class': 'm-thin' }, cage);
      cageLift = b.h + 10;
      setCage(done);

      ball = el('g', { 'class': 'm-ball' }, svg);
      el('circle', { cx: 0, cy: 0, r: R }, ball);
      el('circle', { cx: -2.2, cy: -2.4, r: 1.8, 'class': 'm-shine' }, ball);
      parts.forEach(function (_, i) { setPart(i, open[i], true); });
      s = done ? total : (keep < gates.length ? (started ? gates[keep] : 0) : total);
      v = 0;
      draw();
      mark();
    }

    function setPart(i, isOpen, quiet) {
      var p = parts[i];
      if (!p) return;
      if (p.kind === 'flap') p.node.style.transform = isOpen ? 'rotate(' + (-p.dir * 82) + 'deg)' : 'none';
      if (p.kind === 'bolt') p.node.style.transform = isOpen ? 'translateY(' + (-(2 * R + 14)) + 'px)' : 'none';
      if (p.kind === 'wheel' && isOpen && !quiet) { wheelRot += 90; p.node.style.transform = 'rotate(' + wheelRot + 'deg)'; }
      if (isOpen && !quiet) ripple(p.x, p.y);
    }
    function ripple(x, y) {
      var c = el('circle', { cx: x, cy: y, r: 10, 'class': 'm-ripple' }, svg);
      c.style.transformOrigin = x + 'px ' + y + 'px';
      setTimeout(function () { if (c.parentNode) c.parentNode.removeChild(c); }, 900);
    }
    function setCage(up) {
      if (!cage) return;
      cage.style.transform = up ? 'translateY(' + (-cageLift) + 'px)' : 'none';
      rope.setAttribute('y2', up ? ropeY - cageLift : ropeY);
    }
    function mark() {
      var k = -1;
      if (started && !done) for (var i = 0; i < gates.length; i++) if (!open[i]) { if (s >= gates[i] - 0.5) k = i; break; }
      steps.forEach(function (n, j) { n.classList.toggle('is-waiting', j === k); });
    }

    // Where is the ball after travelling len pixels along the path?
    function at(len) {
      var i = 1;
      while (i < cum.length - 1 && cum[i] < len) i++;
      var a = pts[i - 1], b = pts[i], seg = cum[i] - cum[i - 1] || 1, t = Math.max(0, Math.min(1, (len - cum[i - 1]) / seg));
      return { x: a[0] + (b[0] - a[0]) * t, y: a[1] + (b[1] - a[1]) * t, sin: (b[1] - a[1]) / seg, vert: Math.abs(b[0] - a[0]) < 1 };
    }
    function draw() { var p = at(s); ball.setAttribute('transform', 'translate(' + p.x.toFixed(1) + ' ' + p.y.toFixed(1) + ')'); }
    function limit() { for (var i = 0; i < gates.length; i++) if (!open[i]) return gates[i]; return total; }

    // Simple physics: gravity pulls along the slope, a little friction, a soft landing after each drop.
    function tick(t) {
      var dt = Math.min(0.032, (t - last) / 1000 || 0.016);
      last = t;
      var p = at(s), lim = limit();
      v = Math.min(760, v + (p.vert ? G : G * p.sin - 30) * dt);
      if (!p.vert) v = Math.max(v, 260);
      var ns = Math.min(lim, s + v * dt), q = at(ns);
      if (p.vert && !q.vert) v *= 0.5;
      s = ns;
      draw();
      if (s >= total - 0.5) { raf = 0; unlock(); return; }
      if (s >= lim - 0.01) { v = 0; raf = 0; mark(); return; }
      raf = requestAnimationFrame(tick);
    }
    function go() {
      if (!started) return;
      mark();
      if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); }
    }
    function unlock() { done = true; mark(); setCage(true); form.classList.add('is-unlocked'); btn.removeAttribute('aria-disabled'); hint.textContent = ''; }
    function lock() { done = false; setCage(false); form.classList.remove('is-unlocked'); btn.setAttribute('aria-disabled', 'true'); }

    // Answers changed: open or close gates. Closing a gate the ball already passed sends it back to the start.
    function sync() {
      var restart = false;
      steps.forEach(function (_, i) {
        var o = ok(i);
        if (o !== open[i]) { open[i] = o; setPart(i, o); if (!o && s > gates[i]) restart = true; }
      });
      if (restart) { lock(); if (raf) { cancelAnimationFrame(raf); raf = 0; } s = 0; v = 0; draw(); }
      go();
    }
    var timer = 0;
    function later() { clearTimeout(timer); timer = setTimeout(sync, 160); }
    form.addEventListener('input', later);
    form.addEventListener('change', later);

    layout();
    if ('ResizeObserver' in window) {
      var rt = 0;
      new ResizeObserver(function () {
        clearTimeout(rt);
        rt = setTimeout(function () { if (raf) { cancelAnimationFrame(raf); raf = 0; } layout(); go(); }, 120);
      }).observe(form);
    }
    new IntersectionObserver(function (entries, io) {
      if (entries[0].isIntersecting) { started = true; go(); io.disconnect(); }
    }, { threshold: 0.35 }).observe(form);

    return { unlocked: function () { return done; } };
  })();
})();

When to use it, and when not to

Use it where filling in the form is part of the fun: a quiz, a contest entry, a short survey at the end of an event, a playful sign up page for a product with a sense of humour. Our quick tool finder on the Vaelolab home page uses the same idea with three questions.

Do not use it on checkout, booking or account forms. People there want speed, and every second the ball takes is a second they wait. Keep it to three or four questions; beyond that the joke wears off before the ball arrives.

Two choices keep it fair for everyone. The button is never truly disabled, it carries aria-disabled and explains itself in a live region, so keyboard and screen reader users get a message instead of silence. And anyone who has asked their system for reduced motion gets the plain form with a working button, no ball at all.

Make it yours

  • Colours: change the five custom properties at the top of the CSS. The machine reads every colour from them.
  • Speed: raise G for stronger gravity, or the minimum speed of 260 pixels per second in tick() for a quicker trip.
  • More questions: add another .rgf-step. Mechanisms repeat in the order wheel, trapdoor, bolt.
  • Other rules: edit ok(i), for example to require a valid email with input.checkValidity().

Frequently asked questions

Does it need a framework?

No. It is plain JavaScript and SVG, so it works in any site, including a static page or a CMS that lets you paste HTML.

Will it slow the page down?

The animation only runs while the ball is moving and starts when the form scrolls into view. When the ball waits at a gate, nothing runs at all.

What happens on a phone?

The machine measures the form again whenever its size changes, so the rails and pipes follow the layout when the questions wrap onto more lines.

What should your website do?

Describe a feature you wish your site had. If it is useful to other owners too, we build it here as a free Lab post with a live demo and code.

We never publish your name, email or website. Privacy

More from the Website Lab

All experiments