Slingshot slider: launch a stone to pick a guest count

Pull the gold stone back, let go, and it lands on a number. The ordinary slider underneath still does the real work, for keyboards, screen readers and quick taps.

A catering form usually opens with the dullest question on the page: how many guests? People type a number, or drag a plain slider, and move on. If you run a bakery, a lunch caterer or a party rental business, that first question is also a chance to make the form feel a little less like paperwork. This slingshot slider lets the visitor pull a gold stone back, let go, and watch it land on a guest count. The plain slider underneath stays the real control, so a quick tap or the arrow keys still do the job.

Pull the gold stone back and let go to launch it. Fine tune with the slider underneath or the arrow keys.

30 guests About 3 sharing platters and 2 dozen sandwiches.

Live demo. It runs on exactly the code further down this page.

How it works

  1. A normal range input does the real work. The guest count lives in an ordinary <input type="range"> with a label. That is what a form submits, what a screen reader announces and what the keyboard moves.
  2. The lane above is decoration. The green lane, the fork, the band and the stone are marked aria-hidden, so assistive technology skips them and nothing is said twice.
  3. Pull distance becomes power. While you drag the stone to the left, the script measures how far it has travelled and turns that into a share of the full range. A dashed ring shows where the stone will land before you let go.
  4. The flight lands on the thumb. On release the stone follows a short arc to the exact spot above the slider thumb for that value, the band springs back with a small wobble, and the range value updates.
  5. Everything stops when it lands. The animation runs only during a launch, then the stone fades and reloads in the fork. With reduced motion turned on, it jumps straight to the result.

The code

Three blocks, no libraries: the HTML for the question and slider, the CSS for the lane, and a short script for the pull and the flight.

HTML
<div class="sl" data-lab="slingshot">
  <label class="sl-q" for="sl-range">How many guests are you catering for?</label>
  <p class="sl-sub" id="sl-help">Pull the gold stone back and let go to launch it. Fine tune with the slider underneath or the arrow keys.</p>
  <div class="sl-lane" aria-hidden="true">
    <span class="sl-strip"></span>
    <span class="sl-aim"></span>
    <span class="sl-flag"></span>
    <svg class="sl-band" focusable="false"><line class="sl-b1"></line><line class="sl-b2"></line></svg>
    <span class="sl-fork"></span>
    <span class="sl-stone"></span>
  </div>
  <div class="sl-row">
    <input class="sl-range" id="sl-range" type="range" min="10" max="150" step="5" value="30" aria-describedby="sl-help">
  </div>
  <div class="sl-ends" aria-hidden="true"><span>10</span><span>150</span></div>
  <p class="sl-out"><output class="sl-val" for="sl-range">30 guests</output> <span class="sl-note">About 3 sharing platters and 2 dozen sandwiches.</span></p>
</div>
CSS
.sl {
  --pull: 128px;          /* room on the left for pulling the stone back */
  max-width: 560px;
  margin: 0 auto;
  padding: 8px 4px;
  font-family: system-ui, sans-serif;
  color: #101a14;
}
.sl .sl-q { display: block; font: 700 19px/1.3 system-ui, sans-serif; color: #133621; }
.sl .sl-sub { margin: 4px 0 16px; font-size: 14px; line-height: 1.45; color: #46534a; }
/* The lane is decoration: the real control is the range input below it. */
.sl-lane {
  position: relative;
  height: 104px;
  border-radius: 16px;
  background: #133621;
  overflow: hidden;
}
.sl-strip {
  position: absolute; top: 58px; left: 0; width: 0; height: 0;
  border-top: 2px dashed rgba(244, 217, 108, .35);
}
.sl-band { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.sl-band line { stroke: #e9d48a; stroke-width: 3; stroke-linecap: round; }
/* A sideways fork: two prongs open to the right, the stone sits between them. */
.sl-fork {
  position: absolute; top: 34px; left: 0; width: 16px; height: 48px;
  border: 5px solid #c9cec4; border-right: 0; border-radius: 10px 0 0 10px;
  box-sizing: border-box;
}
.sl-fork::before {
  content: ""; position: absolute; top: 16px; left: -22px; width: 18px; height: 6px;
  border-radius: 3px; background: #c9cec4;
}
.sl-stone {
  position: absolute; top: 0; left: 0; width: 28px; height: 28px;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 35%, #fff6cf, #f4d96c 45%, #c9a63a);
  box-shadow: 0 2px 6px rgba(0, 0, 0, .35);
  cursor: grab; touch-action: none;
  transition: opacity .25s;
}
/* A bigger invisible hit area makes the stone easy to grab on a phone. */
.sl-stone::after { content: ""; position: absolute; inset: -10px; border-radius: 50%; }
.sl.is-pull .sl-stone { cursor: grabbing; }
.sl-stone.is-gone { opacity: 0; }
.sl-aim {
  position: absolute; top: 44px; left: 0; width: 28px; height: 28px; margin-left: -14px;
  border: 2px dashed #f4d96c; border-radius: 50%; opacity: 0; transition: opacity .15s;
}
.sl.is-pull .sl-aim { opacity: .9; }
.sl-flag {
  position: absolute; top: 34px; left: 0; width: 4px; height: 26px; margin-left: -2px;
  background: #f2f3ef; border-radius: 2px; transition: left .25s ease-out;
}
.sl-flag::after {
  content: ""; position: absolute; top: 0; left: 4px;
  border-left: 14px solid #f4d96c; border-top: 7px solid transparent; border-bottom: 7px solid transparent;
}
.sl-flag.is-hit { animation: sl-hit .4s ease-out; }
@keyframes sl-hit { 40% { transform: scaleY(1.25); } }
.sl-row { padding-left: var(--pull); margin-top: 10px; }
.sl-ends { display: flex; justify-content: space-between; padding-left: var(--pull); font-size: 12px; color: #46534a; }
/* Long selectors on purpose: they outrank range styles a site theme may already have. */
.sl .sl-row .sl-range[type=range] {
  -webkit-appearance: none; appearance: none;
  display: block; width: 100%; height: 28px; margin: 0; padding: 0;
  background: transparent; cursor: pointer;
}
.sl .sl-row .sl-range[type=range]::-webkit-slider-runnable-track { height: 8px; border-radius: 8px; background: #e4e7df; border: 1px solid #c9cec4; }
.sl .sl-row .sl-range[type=range]::-moz-range-track { height: 8px; border-radius: 8px; background: #e4e7df; border: 1px solid #c9cec4; }
.sl .sl-row .sl-range[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; box-sizing: border-box; width: 24px; height: 24px; margin-top: -9px;
  border: 3px solid #133621; border-radius: 50%; background: #f4d96c;
}
.sl .sl-row .sl-range[type=range]::-moz-range-thumb { box-sizing: border-box; width: 24px; height: 24px; border: 3px solid #133621; border-radius: 50%; background: #f4d96c; }
.sl .sl-row .sl-range[type=range]:focus { outline: none; }
.sl .sl-row .sl-range[type=range]:focus-visible { outline: 3px solid #f4d96c; outline-offset: 3px; border-radius: 14px; }
.sl .sl-out { margin: 14px 0 0; font-size: 15px; line-height: 1.45; color: #46534a; }
.sl .sl-val { font-weight: 700; color: #133621; margin-right: 4px; }
@media (max-width: 420px) {
  .sl { --pull: 112px; }
}
@media (prefers-reduced-motion: reduce) {
  .sl-flag { transition: none; }
  .sl-flag.is-hit { animation: none; }
}
JavaScript
(function () {
  var root = document.querySelector('[data-lab="slingshot"]');
  if (!root) return;
  var lane = root.querySelector('.sl-lane');
  var stone = root.querySelector('.sl-stone');
  var fork = root.querySelector('.sl-fork');
  var strip = root.querySelector('.sl-strip');
  var aim = root.querySelector('.sl-aim');
  var flag = root.querySelector('.sl-flag');
  var b1 = root.querySelector('.sl-b1'), b2 = root.querySelector('.sl-b2');
  var range = root.querySelector('.sl-range');
  var val = root.querySelector('.sl-val');
  var note = root.querySelector('.sl-note');
  var THUMB = 24;           // range thumb width from the CSS, so the stone lands above it
  var MID = 58;             // height of the flight line inside the lane
  var calm = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var min = +range.min, max = +range.max, step = +range.step;
  var forkX = 0, pullMax = 0, pulling = false, busy = false;
  var sx = 0, sy = 0, dx = 0, dy = 0;

  // Where a value sits along the lane: right above the range thumb for that value.
  function xFor(v) {
    var l = lane.getBoundingClientRect(), r = range.getBoundingClientRect();
    return r.left - l.left + THUMB / 2 + (v - min) / (max - min) * (r.width - THUMB);
  }
  // A full pull launches to the top value, a light pull to the bottom.
  function valueFor(power) { return min + Math.round(power * (max - min) / step) * step; }

  function band(x, y) {
    b1.setAttribute('x2', x); b1.setAttribute('y2', y);
    b2.setAttribute('x2', x); b2.setAttribute('y2', y);
  }
  function put(x, y) { stone.style.transform = 'translate(' + (x - 14) + 'px,' + (y - 14) + 'px)'; }

  function layout() {
    forkX = range.getBoundingClientRect().left - lane.getBoundingClientRect().left - 24;
    pullMax = forkX - 22;
    fork.style.left = (forkX - 12) + 'px';
    b1.setAttribute('x1', forkX + 4); b1.setAttribute('y1', 37);
    b2.setAttribute('x1', forkX + 4); b2.setAttribute('y1', 79);
    strip.style.left = xFor(min) + 'px';
    strip.style.width = (xFor(max) - xFor(min)) + 'px';
    if (!busy) { put(forkX, MID); band(forkX, MID); }
    show(false);
  }

  function show(hit) {
    var v = +range.value;
    val.textContent = v + ' guests';
    note.textContent = 'About ' + Math.ceil(v / 10) + ' sharing platters and ' + Math.ceil(v / 15) + ' dozen sandwiches.';
    flag.style.left = xFor(v) + 'px';
    if (hit) { flag.classList.remove('is-hit'); void flag.offsetWidth; flag.classList.add('is-hit'); }
  }

  function launch(v, rx, ry) {
    busy = true;
    var power = (forkX - rx) / pullMax, end = xFor(v), arc = 12 + 32 * power;
    var dur = calm ? 0 : 380 + 380 * power, t0 = performance.now();
    function frame(now) {
      var t = dur ? Math.min(1, (now - t0) / dur) : 1, s = (now - t0) / 1000;
      // The stone eases along an arc; the band springs back with a damped wobble.
      var e = 1 - Math.pow(1 - t, 2);
      put(forkX + (end - forkX) * e, MID - Math.sin(Math.PI * t) * arc);
      var k = calm ? 0 : Math.exp(-7 * s) * Math.cos(20 * s);
      band(forkX + (rx - forkX) * k, MID + (ry - MID) * k);
      if (t < 1 || Math.abs(k) > .02) return requestAnimationFrame(frame);
      band(forkX, MID);
      range.value = v;
      show(true);
      // Fade the stone out where it landed and reload it in the fork.
      setTimeout(function () {
        stone.classList.add('is-gone');
        setTimeout(function () {
          put(forkX, MID); stone.classList.remove('is-gone'); busy = false;
        }, 260);
      }, 450);
    }
    requestAnimationFrame(frame);
  }

  stone.addEventListener('pointerdown', function (e) {
    if (busy) return;
    pulling = true; sx = e.clientX; sy = e.clientY; dx = 0; dy = 0;
    stone.setPointerCapture(e.pointerId);
    root.classList.add('is-pull');
  });
  stone.addEventListener('pointermove', function (e) {
    if (!pulling) return;
    dx = Math.max(0, Math.min(pullMax, sx - e.clientX));
    dy = Math.max(-18, Math.min(18, e.clientY - sy));
    put(forkX - dx, MID + dy); band(forkX - dx, MID + dy);
    aim.style.left = xFor(valueFor(dx / pullMax)) + 'px';
  });
  function release() {
    if (!pulling) return;
    pulling = false; root.classList.remove('is-pull');
    if (dx < 6) { put(forkX, MID); band(forkX, MID); return; }
    launch(valueFor(dx / pullMax), forkX - dx, MID + dy);
  }
  stone.addEventListener('pointerup', release);
  stone.addEventListener('pointercancel', function () { dx = 0; release(); });

  range.addEventListener('input', function () { show(false); });
  window.addEventListener('resize', layout);
  layout();
})();

When to use it, and when not to

This works best on a single, low stakes number near the top of a friendly form: guest counts for a party platter, how many people for a cooking class, how many bags for a laundry pickup. It gives a first time visitor something to play with, and the playful part never blocks the plain way of answering.

It is a poor fit where precision matters or where people are in a hurry. A slingshot is a rough aim, so for a booking time, a budget or anything with a price attached, a number field or a normal slider is kinder. It also takes up more space than a regular input, so one per form is plenty. Keep the visible instruction and the arrow key fallback: some visitors cannot drag at all, and they should never feel they missed something.

Make it yours

  • The range itself: change min, max and step on the input. The launch power maps onto whatever range you set, through valueFor().
  • The words: the label, the help line and the sentence built inside show() are all plain text you can rewrite for your own product.
  • The feel: the arc and dur values inside launch() set how high and how slowly the stone flies, and --pull in the CSS sets how much room there is to pull back.

Frequently asked questions

Does it work on phones?

Yes. It uses pointer events, so a finger works the same as a mouse, and the stone has a larger invisible grab area. The pull room shrinks a little on narrow screens.

Will the form submit the right number?

Yes. The value always comes from the real range input, so the slingshot only changes that input; give it a name attribute and it submits like any other field.

What if someone cannot drag?

They can tab to the slider and use the arrow keys, or tap along the track. The help text says so above the lane, and a screen reader reads out the new guest count as the slider moves.

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