Stubborn slider: a range that pushes back at its limit

Pull the handle past the number you can book online and it resists, bounces back and tells people what to do instead. A dead end turned into a clear next step.

A restaurant takes online bookings for up to eight people. Anything bigger needs a phone call, because someone has to push tables together and check the kitchen can cope. Most booking widgets handle this with a dropdown that silently stops at 8, so the person planning a birthday for twelve keeps scrolling, finds nothing, and wonders if you take groups at all. A stubborn slider shows the whole range, lets people pull past the limit, resists like elastic, then springs back and tells them exactly what to do instead.

How many people are coming?

2

Online booking holds tables for up to 8 people.

Table for 2, Friday at 7:00 pm

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

How it works

  1. Show more than you allow. The track runs from 1 to 12, but the slider only accepts 1 to 8. The part beyond 8 is striped, so the limit is visible before anyone reaches it.
  2. Follow the pointer, then resist. While dragging, the script turns the pointer position into a number. Past the limit it stops following one to one and uses a curve that gives less and less, so the handle stretches a little and turns gold, like pulling on a rubber band.
  3. Spring back and explain. On release the handle bounces back to 8 with a short overshoot, and a live region says that larger groups should call. The value never goes above the limit, so the booking stays valid the whole time.
  4. Keyboard and screen readers. The handle is a real role="slider" with the arrow keys, Home and End. Pressing right at 8 gives a small nudge and the same message, and the reported maximum is 8, which is the truth.

The code

Three blocks, no libraries: paste them into a page or a custom HTML section and change the numbers at the top of the script.

HTML
<div class="ss" data-lab="stubborn-slider">
  <p class="ss-q" id="ss-label">How many people are coming?</p>
  <div class="ss-wrap">
    <div class="ss-track" aria-hidden="true">
      <span class="ss-zone"></span>
      <span class="ss-fill"></span>
    </div>
    <div class="ss-thumb" role="slider" tabindex="0" aria-labelledby="ss-label" aria-describedby="ss-note"
      aria-valuemin="1" aria-valuemax="8" aria-valuenow="2" aria-valuetext="2 people"><span class="ss-num">2</span></div>
  </div>
  <div class="ss-scale" aria-hidden="true"><span>1</span><span class="ss-max">8</span><span>12+</span></div>
  <p class="ss-note" id="ss-note">Online booking holds tables for up to 8 people.</p>
  <p class="ss-sum">Table for <strong class="ss-count">2</strong>, Friday at 7:00 pm</p>
  <p class="ss-status" aria-live="polite"></p>
</div>
CSS
.ss {
  --ss-limit: 63.64%;     /* where 8 sits on a 1 to 12 track: (8 - 1) / 11 */
  max-width: 460px;
  margin: 0 auto;
  padding: 26px 26px 20px;
  border: 1px solid #c9cec4;
  border-radius: 16px;
  background: #fff;
  color: #101a14;
  font-family: system-ui, sans-serif;
  line-height: 1.4;
  box-sizing: border-box;
}
.ss p { margin: 0; }      /* spacing below uses padding, so page styles for p cannot change it */
.ss-q { font-size: 18px; font-weight: 700; color: #133621; }
.ss-wrap {
  position: relative;
  height: 56px;
  margin: 14px 18px 0;
  cursor: pointer;
  touch-action: pan-y;    /* sideways drags move the slider, vertical swipes still scroll */
  user-select: none;
  -webkit-user-select: none;
}
.ss-track {
  position: absolute;
  left: 0; right: 0; top: 50%;
  height: 8px;
  margin-top: -4px;
  border-radius: 8px;
  background: #e4e7e0;
}
/* The part of the scale you cannot book online, shown with soft stripes. */
.ss-zone {
  position: absolute;
  top: 0; bottom: 0; right: 0;
  left: calc(var(--ss-limit) + 4.5%);
  border-radius: 0 8px 8px 0;
  background: repeating-linear-gradient(135deg, #e4e7e0 0 6px, #c9cec4 6px 12px);
}
.ss-fill {
  position: absolute;
  top: 0; bottom: 0; left: 0;
  width: 9.09%;
  border-radius: 8px;
  background: #133621;
}
.ss-thumb {
  --ss-s: 1;
  position: absolute;
  top: 50%;
  left: 9.09%;
  width: 44px;
  height: 44px;
  margin: -22px 0 0 -22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: #133621;
  color: #f4d96c;
  font: 700 17px/1 system-ui, sans-serif;
  box-shadow: 0 2px 8px rgba(16, 26, 20, .25);
  transform: scaleX(var(--ss-s));
  outline: none;
}
.ss-thumb:focus-visible { box-shadow: 0 0 0 3px #fff, 0 0 0 6px #f4d96c; }
.ss-thumb.is-strain { background: #f4d96c; color: #133621; }
/* After a stretch, both parts spring back to the limit. */
.ss.is-back .ss-thumb { transition: left .45s cubic-bezier(.3, 1.6, .5, 1), transform .45s; }
.ss.is-back .ss-fill { transition: width .45s cubic-bezier(.3, 1.6, .5, 1); }
.ss-thumb.is-nudge { animation: ss-nudge .35s ease; }
@keyframes ss-nudge { 40% { margin-left: -14px; } }
.ss-scale {
  display: flex;
  justify-content: space-between;
  position: relative;
  margin: 2px 18px 0;
  font-size: 13px;
  color: #46534a;
}
.ss-max { position: absolute; left: var(--ss-limit); transform: translateX(-50%); font-weight: 700; color: #133621; }
.ss-note { padding-top: 16px; font-size: 14px; color: #46534a; }
.ss-sum { padding-top: 10px; font-size: 16px; }
.ss-status { min-height: 2.8em; padding-top: 8px; font-size: 15px; font-weight: 600; color: #133621; }
@media (max-width: 420px) { .ss { padding: 20px 16px 14px; } }
@media (prefers-reduced-motion: reduce) {
  .ss.is-back .ss-thumb, .ss.is-back .ss-fill { transition: none; }
  .ss-thumb.is-nudge { animation: none; }
}
JavaScript
(function () {
  var root = document.querySelector('[data-lab="stubborn-slider"]');
  if (!root) return;
  var wrap = root.querySelector('.ss-wrap');
  var thumb = root.querySelector('.ss-thumb');
  var fill = root.querySelector('.ss-fill');
  var count = root.querySelector('.ss-count');
  var status = root.querySelector('.ss-status');
  var MIN = 1, LIMIT = 8, SCALE_MAX = 12;   // the track shows 1 to 12, booking stops at 8
  var PULL = 0.07;                          // how far past the limit the thumb can be dragged
  var MESSAGE = 'Groups of 9 or more: call us on 555 0100 and we will set up the long table.';
  var value = 2, dragging = false, strained = false, backTimer = 0;

  function frac(v) { return (v - MIN) / (SCALE_MAX - MIN); }

  // Place thumb and fill at a fraction of the track (0 to 1).
  function place(f, stretch) {
    thumb.style.left = (f * 100) + '%';
    fill.style.width = (f * 100) + '%';
    thumb.style.setProperty('--ss-s', stretch || 1);
  }

  function setValue(v) {
    v = Math.max(MIN, Math.min(LIMIT, v));
    if (v !== value) status.textContent = '';
    value = v;
    thumb.querySelector('.ss-num').textContent = v;
    count.textContent = v;
    thumb.setAttribute('aria-valuenow', v);
    thumb.setAttribute('aria-valuetext', v + (v === 1 ? ' person' : ' people'));
  }

  function fromPointer(e) {
    var r = wrap.getBoundingClientRect();
    var raw = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
    var lim = frac(LIMIT);
    if (raw <= lim) {
      strained = false;
      thumb.classList.remove('is-strain');
      setValue(Math.round(MIN + raw * (SCALE_MAX - MIN)));
      place(raw);
      return;
    }
    // Past the limit: the thumb follows less and less, like pulling on elastic.
    var give = PULL * (1 - Math.exp(-(raw - lim) / 0.12));
    strained = give > 0.01;
    thumb.classList.toggle('is-strain', strained);
    setValue(LIMIT);
    place(lim + give, 1 + give * 3);
  }

  function springBack() {
    root.classList.add('is-back');
    place(frac(value));
    clearTimeout(backTimer);
    backTimer = setTimeout(function () { root.classList.remove('is-back'); }, 460);
  }

  function refuse() {
    status.textContent = MESSAGE;
    thumb.classList.remove('is-nudge');
    void thumb.offsetWidth;               // restart the nudge animation
    thumb.classList.add('is-nudge');
  }

  wrap.addEventListener('pointerdown', function (e) {
    dragging = true;
    wrap.setPointerCapture(e.pointerId);
    root.classList.remove('is-back');
    thumb.focus();
    fromPointer(e);
  });
  wrap.addEventListener('pointermove', function (e) { if (dragging) fromPointer(e); });
  function release() {
    if (!dragging) return;
    dragging = false;
    if (strained) status.textContent = MESSAGE;
    strained = false;
    thumb.classList.remove('is-strain');
    springBack();
  }
  wrap.addEventListener('pointerup', release);
  wrap.addEventListener('pointercancel', release);

  thumb.addEventListener('keydown', function (e) {
    var k = e.key, next = value;
    if (k === 'ArrowRight' || k === 'ArrowUp') next = value + 1;
    else if (k === 'ArrowLeft' || k === 'ArrowDown') next = value - 1;
    else if (k === 'Home') next = MIN;
    else if (k === 'End' || k === 'PageUp') next = LIMIT;
    else return;
    e.preventDefault();
    if (next > LIMIT) { refuse(); return; }
    setValue(next);
    springBack();
  });
  thumb.addEventListener('animationend', function () { thumb.classList.remove('is-nudge'); });

  place(frac(value));
})();

When to use it, and when not to

It works well wherever there is a soft limit with a human answer behind it: party size for a table, number of guests for a class or tour, square footage a cleaner quotes online, the number of rooms a painter will price without a visit. In each case the person who goes over the limit is not doing anything wrong. They are often your biggest order of the week, and the elastic pull turns a dead end into a clear next step.

Do not use it for hard limits that have no alternative, such as a legal maximum or a sold out event. Pulling against something that never gives anything back is just frustrating. It is also overkill for small ranges; if the choices are 1 to 4, a row of four buttons is faster for everyone.

On accessibility, the message matters more than the bounce. Keep the limit written in plain text under the slider, as the demo does, so people know it before they try. If the extra numbers on the track confuse your visitors, you can drop them and keep only the striped zone with a short label.

Make it yours

  • The numbers: change LIMIT for the online maximum and SCALE_MAX for how far the track runs. Then update --ss-limit in the CSS, which is (LIMIT - 1) / (SCALE_MAX - 1) as a percentage, plus aria-valuemax in the HTML.
  • The answer: edit MESSAGE with your real phone number or a link to an enquiry form for large groups.
  • The feel: PULL sets how far past the limit the handle will travel, and the cubic-bezier on .ss.is-back sets how bouncy the return is.

Frequently asked questions

Can I send the chosen number with a booking form?

Yes. Add a hidden input inside your form and set its value in setValue(), or read aria-valuenow from the handle when the form is submitted.

Does dragging the slider stop the page from scrolling on phones?

No. The track uses touch-action: pan-y, so a sideways drag moves the handle while an upward or downward swipe still scrolls the page.

Why not just use a normal range input?

A native range input cannot travel past its maximum, so there is nothing to pull against. If you do not want the elastic effect, a native input with a note underneath is a perfectly good choice.

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