← Forms

Range Slider Bubble

A bubble with the value in it rides along above the slider thumb. Grab the thumb and the bubble grows. Drag and it swings behind like it’s on a stalk, then settles upright when you stop. The second slider only shows its bubble while you use it.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a range input in an element with data-range-slider-bubble.
  3. The real input stays on top, invisible, so keyboard, forms and screen readers work as usual. Leave a little room above it for the bubble.

Options

Add any of these attributes to the same element.

data-prefixText before the number, such as $.
data-suffixText after the number, such as %.
data-colorTrack and bubble colour. Default #6a0dad.
data-hideOnly show the bubble while the slider is in use.
data-autoplayIt slides itself to new values while in view, and stops as soon as you use it.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Range Slider Bubble: a value bubble rides along above the slider thumb.
    // Grab the thumb and the bubble grows; drag it and the bubble swings
    // behind like it's on a stalk, settling upright when you stop.
    // Usage: <div data-range-slider-bubble><input type="range" min="0" max="100" value="40"></div>
    //   The native range input stays (invisible, on top) so keyboard, forms
    //   and screen readers work as usual. Leave room above it for the bubble.
    // Optional: data-prefix and data-suffix (text around the number, e.g. "$"
    // or "%"), data-color (track and bubble colour, default #6a0dad),
    // data-hide (only show the bubble while you're using the slider),
    // data-autoplay (it slides itself to new values while in view; it stops
    // as soon as you use it). Without an input (a mock): data-min, data-max,
    // data-value.
    var DEFAULTS = { color: '#6a0dad', min: 0, max: 100, value: 50 };

    var css =
      '.rsb{position:relative;box-sizing:border-box;padding-top:3em;-webkit-tap-highlight-color:transparent}' +
      '.rsb-rail{position:relative;height:1.4em}' +
      '.rsb-track{position:absolute;left:0;right:0;top:50%;height:.42em;margin-top:-.21em;border-radius:1em;background:#e4e4e7}' +
      '.rsb-fill{position:absolute;left:0;top:0;bottom:0;border-radius:inherit;background:var(--rsb-color);width:calc(.7em + var(--rsb-p) * (100% - 1.4em))}' +
      '.rsb-thumb,.rsb-bubble{position:absolute;left:calc(.7em + var(--rsb-p) * (100% - 1.4em))}' +
      '.rsb-thumb{top:50%;width:1.4em;height:1.4em;margin:-.7em 0 0 -.7em;box-sizing:border-box;border-radius:50%;background:#fff;' +
      'border:.2em solid var(--rsb-color);box-shadow:0 2px 6px rgba(15,15,30,.18);transition:box-shadow .2s}' +
      '.rsb-active .rsb-thumb{box-shadow:0 2px 6px rgba(15,15,30,.18),0 0 0 .4em var(--rsb-soft)}' +
      '.rsb-bubble{bottom:100%;width:0;height:0}' +
      '.rsb-tag{position:absolute;left:0;bottom:.55em;display:block;min-width:2.2em;padding:.28em .55em;box-sizing:border-box;border-radius:.55em;' +
      'background:var(--rsb-color);color:#fff;font-weight:700;font-size:.92em;line-height:1.25;text-align:center;white-space:nowrap;' +
      'font-variant-numeric:tabular-nums;transform:translateX(-50%);transform-origin:50% 100%}' +
      '.rsb-tag::after{content:"";position:absolute;left:50%;top:100%;margin-left:-.4em;border:.4em solid transparent;border-top-color:var(--rsb-color);border-bottom:0}' +
      '.rsb-input{position:absolute;left:0;top:-.6em;width:100%;height:calc(100% + 1.2em);margin:0;padding:0;font:inherit;opacity:0;cursor:pointer;-webkit-appearance:none;appearance:none;touch-action:pan-y}' +
      '.rsb-input::-webkit-slider-thumb{-webkit-appearance:none;width:1.4em;height:1.4em}' +
      '.rsb-input::-moz-range-thumb{width:1.4em;height:1.4em;border:0}' +
      '.rsb-focus .rsb-thumb{outline:2px solid var(--rsb-color);outline-offset:3px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function rgba(hex, a) {
      var m = /^#?([0-9a-f]{6})$/i.exec(hex);
      if (!m) return 'rgba(106,13,173,' + a + ')';
      var n = parseInt(m[1], 16);
      return 'rgba(' + (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
    }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var input = mock ? null : el.querySelector('input[type="range"]');
      var color = d.color || DEFAULTS.color;
      var num = function (v, f) { v = parseFloat(v); return isNaN(v) ? f : v; };
      var min = input ? num(input.min, 0) : num(d.min, DEFAULTS.min);
      var max = input ? num(input.max, 100) : num(d.max, DEFAULTS.max);
      var value = input ? num(input.value, (min + max) / 2) : num(d.value, DEFAULTS.value);
      var step = input ? (input.step === 'any' ? 0 : num(input.step, 1)) : 1;
      var decimals = step && step % 1 ? String(step).split('.')[1].length : 0;
      el.classList.add('rsb');
      el.style.setProperty('--rsb-color', color);
      el.style.setProperty('--rsb-soft', rgba(color, 0.16));

      var rail = document.createElement('div');
      rail.className = 'rsb-rail';
      rail.innerHTML = '<span class="rsb-track" aria-hidden="true"><span class="rsb-fill"></span></span><span class="rsb-thumb" aria-hidden="true"></span>' +
        '<span class="rsb-bubble" aria-hidden="true"><span class="rsb-tag"></span></span>';
      var tag = rail.querySelector('.rsb-tag');
      if (input) { input.classList.add('rsb-input'); rail.appendChild(input); }
      el.appendChild(rail);

      var hide = on(d.hide);
      var pre = d.prefix || '', suf = d.suffix || '';
      var lastP = null;
      function show(v) {
        value = v;
        var p = max > min ? (v - min) / (max - min) : 0;
        el.style.setProperty('--rsb-p', p.toFixed(4));
        var shown = step ? Math.round(v / step) * step : v;
        tag.textContent = pre + shown.toFixed(decimals) + suf;
      }
      show(value);

      // The bubble's swing: a spring that leans away from the direction of travel.
      var tilt = 0, spin = 0, grow = hide ? 0 : 1, growTo = grow, active = false, raf = 0, prevP = null;
      function paint() {
        anime.utils.set(tag, { rotate: tilt, scale: grow, translateX: '-50%' });
      }
      paint();
      function loop() {
        raf = 0;
        var p = parseFloat(el.style.getPropertyValue('--rsb-p')) || 0;
        var v = prevP === null ? 0 : (p - prevP) * rail.offsetWidth;
        prevP = p;
        var target = Math.max(-28, Math.min(28, -v * 2.2));
        spin += (target - tilt) * 0.16;
        spin *= 0.72;
        tilt += spin;
        grow += (growTo - grow) * 0.22;
        paint();
        if (active || Math.abs(tilt) > 0.05 || Math.abs(spin) > 0.05 || Math.abs(growTo - grow) > 0.002) raf = requestAnimationFrame(loop);
        else { tilt = 0; grow = growTo; paint(); prevP = null; }
      }
      function kick() { if (!raf && !reduced()) raf = requestAnimationFrame(loop); }
      function hold(next) {
        active = next;
        el.classList.toggle('rsb-active', next);
        growTo = next ? 1.15 : (hide ? 0 : 1);
        if (reduced()) { grow = growTo; paint(); }
        kick();
      }

      if (input) {
        input.addEventListener('input', function () { show(parseFloat(input.value)); kick(); });
        input.addEventListener('pointerdown', function () { stop(); hold(true); });
        ['pointerup', 'pointercancel', 'blur'].forEach(function (t) { input.addEventListener(t, function () { hold(false); el.classList.remove('rsb-focus'); }); });
        input.addEventListener('keydown', function () { stop(); });
        input.addEventListener('focus', function () { if (input.matches(':focus-visible')) { el.classList.add('rsb-focus'); hold(true); } });
      }

      var timer = null, stopped = false, tween = null;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (tween) tween.pause();
        hold(false);
      }
      if (on(d.autoplay) && !reduced()) {
        var go = function () {
          if (stopped) return;
          var span = max - min, to;
          do { to = min + Math.round((0.08 + Math.random() * 0.84) * span); } while (Math.abs(to - value) < span * 0.25);
          var o = { v: value };
          hold(true);
          tween = anime.animate(o, {
            v: to, duration: 700 + Math.abs(to - value) / span * 900, ease: 'inOut(3)',
            onUpdate: function () { show(o.v); if (input) input.value = o.v; kick(); },
            onComplete: function () { hold(false); timer = setTimeout(go, 1100); }
          });
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (tween) tween.pause();
          if (stopped) return;
          if (entries[0].isIntersecting) timer = setTimeout(go, 600);
          else hold(false);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-range-slider-bubble]').forEach(function (el) {
        if (el._rsb) return;
        el._rsb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-range-slider-bubble data-prefix="$">
  <input type="range" min="0" max="500" step="10" value="180" aria-label="Budget">
</div>