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
- Paste the snippet just before
</head>. - Wrap a range input in an element with
data-range-slider-bubble. - 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-prefix | Text before the number, such as $. |
data-suffix | Text after the number, such as %. |
data-color | Track and bubble colour. Default #6a0dad. |
data-hide | Only show the bubble while the slider is in use. |
data-autoplay | It slides itself to new values while in view, and stops as soon as you use it. |
<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><div data-range-slider-bubble data-prefix="$">
<input type="range" min="0" max="500" step="10" value="180" aria-label="Budget">
</div>