/* Two-thumb range sliders: two real <input type="range"> stacked on one track. The inputs ignore the pointer and
only their thumbs take it, so either thumb can be dragged. The script keeps min <= max and sets
--lo and --hi (0 to 1), which paint the selected stretch of the track. */
.pr{--pr-accent:#e11d48;--pr-track:#8e98a8;--pr-thumb:22px;--pr-h:6px;--lo:0;--hi:1;display:grid;gap:14px}
.pr-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:4px 12px}
.pr-title{font-size:.95rem;font-weight:600;color:#1e293b}
.pr-sum{font-size:.95rem;font-weight:700;color:#be123c;font-variant-numeric:tabular-nums}
.pr-slider{position:relative;height:var(--pr-thumb)}
.pr-slider::before{content:"";position:absolute;left:0;right:0;top:50%;height:var(--pr-h);transform:translateY(-50%);border-radius:999px;
background:linear-gradient(to right,
var(--pr-track) 0 calc(var(--pr-thumb) / 2 + (100% - var(--pr-thumb)) * var(--lo)),
var(--pr-accent) 0 calc(var(--pr-thumb) / 2 + (100% - var(--pr-thumb)) * var(--hi)),
var(--pr-track) 0)}
.pr-slider input[type="range"]{-webkit-appearance:none;appearance:none;position:absolute;inset:0;width:100%;height:100%;
margin:0;background:none;pointer-events:none}
.pr-slider input[type="range"]:focus-visible{outline:none}
.pr-slider input[type="range"]::-webkit-slider-runnable-track{height:var(--pr-h);background:none}
.pr-slider input[type="range"]::-moz-range-track{height:var(--pr-h);background:none}
.pr-slider input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;box-sizing:border-box;pointer-events:auto;cursor:grab;
width:var(--pr-thumb);height:var(--pr-thumb);margin-top:calc((var(--pr-h) - var(--pr-thumb)) / 2);
border:2px solid var(--pr-accent);border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(15,23,42,.3)}
.pr-slider input[type="range"]::-moz-range-thumb{box-sizing:border-box;pointer-events:auto;cursor:grab;
width:var(--pr-thumb);height:var(--pr-thumb);border:2px solid var(--pr-accent);border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(15,23,42,.3)}
.pr-slider input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pr-accent)}
.pr-slider input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pr-accent)}
/* min / max boxes under the track: visible <label>s for the two inputs */
.pr-boxes{display:flex;align-items:center;gap:10px;margin-top:4px}
.pr-box{flex:1;display:grid;gap:2px;padding:8px 12px;border:1px solid #8e98a8;border-radius:12px}
.pr-box label{font-size:.75rem;color:#5d6472}
.pr-box output{font-size:1rem;font-weight:700;color:#1e293b;font-variant-numeric:tabular-nums}
.pr-dash{color:#5d6472}
/* histogram: bars inside the selected range get the accent (the script sets data-in) */
.pr-bars{display:flex;align-items:flex-end;gap:3px;height:64px;margin:6px calc(var(--pr-thumb) / 2) -12px}
.pr-bars span{flex:1;border-radius:3px 3px 0 0;background:#d9dde4}
.pr-bars span[data-in]{background:#fb7185}
.pr.solid .pr-slider input[type="range"]::-webkit-slider-thumb{border:3px solid #fff;background:var(--pr-accent)}
.pr.solid .pr-slider input[type="range"]::-moz-range-thumb{border:3px solid #fff;background:var(--pr-accent)}
/* time window: pill thumbs and an hour scale */
.pr.time{--pr-thumb:16px;--pr-h:4px}
.pr.time .pr-slider{height:26px}
.pr.time .pr-slider input[type="range"]::-webkit-slider-thumb{height:26px;margin-top:-11px;border-radius:8px;border:0;background:var(--pr-accent)}
.pr.time .pr-slider input[type="range"]::-moz-range-thumb{height:26px;border-radius:8px;border:0;background:var(--pr-accent)}
.pr-scale{display:flex;justify-content:space-between;margin:-4px calc(var(--pr-thumb) / 2) 0;font-size:.78rem;color:#5d6472;font-variant-numeric:tabular-nums}
.pr-scale span{width:0;display:flex;justify-content:center;white-space:nowrap}
JavaScript
// Two-thumb sliders: keeps min <= max, paints the selected range (--lo, --hi) and updates the readouts.
document.querySelectorAll('.pr').forEach(function (group) {
var ranges = group.querySelectorAll('input[type="range"]'), lo = ranges[0], hi = ranges[1];
var min = +lo.min, max = +lo.max, bars = group.querySelectorAll('.pr-bars span');
function fmt(v) {
v = +v;
if (group.dataset.format === 'time') return String(Math.floor(v / 60)).padStart(2, '0') + ':' + String(v % 60).padStart(2, '0');
return '$' + v.toLocaleString('en-US');
}
function sync(e) {
if (+lo.value > +hi.value) { if (e && e.target === lo) lo.value = hi.value; else hi.value = lo.value; }
var a = (lo.value - min) / (max - min), b = (hi.value - min) / (max - min);
group.style.setProperty('--lo', a);
group.style.setProperty('--hi', b);
lo.style.zIndex = (a === b && a > 0.5) ? 2 : ''; // when the thumbs meet on the right, keep the min thumb on top
[lo, hi].forEach(function (r) {
var out = group.querySelector('output[for="' + r.id + '"]');
r.setAttribute('aria-valuetext', fmt(r.value));
if (out) out.textContent = fmt(r.value);
});
var sum = group.querySelector('.pr-sum');
if (sum) sum.textContent = fmt(lo.value) + ' – ' + fmt(hi.value);
bars.forEach(function (bar, i) { var c = (i + 0.5) / bars.length; bar.toggleAttribute('data-in', c >= a && c <= b); });
}
lo.addEventListener('input', sync);
hi.addEventListener('input', sync);
sync();
});
/* Two-thumb range sliders: two Bootstrap .form-range inputs stacked on one track. The inputs ignore the pointer and
only their thumbs take it, so either thumb can be dragged. The script keeps min <= max and sets
--lo and --hi (0 to 1), which paint the selected stretch of the track. */
.pr{--pr-accent:#e11d48;--pr-track:#8e98a8;--pr-thumb:22px;--pr-h:6px;--lo:0;--hi:1;display:grid;gap:14px}
.pr-title{font-size:.95rem;color:#1e293b}
.pr-sum{font-size:.95rem;color:#be123c;font-variant-numeric:tabular-nums}
.pr-slider{position:relative;height:var(--pr-thumb)}
.pr-slider::before{content:"";position:absolute;left:0;right:0;top:50%;height:var(--pr-h);transform:translateY(-50%);border-radius:999px;
background:linear-gradient(to right,
var(--pr-track) 0 calc(var(--pr-thumb) / 2 + (100% - var(--pr-thumb)) * var(--lo)),
var(--pr-accent) 0 calc(var(--pr-thumb) / 2 + (100% - var(--pr-thumb)) * var(--hi)),
var(--pr-track) 0)}
.pr-slider .form-range{position:absolute;inset:0;height:100%;pointer-events:none}
.pr-slider .form-range::-webkit-slider-runnable-track{height:var(--pr-h);background:none}
.pr-slider .form-range::-moz-range-track{height:var(--pr-h);background:none}
.pr-slider .form-range::-webkit-slider-thumb{box-sizing:border-box;pointer-events:auto;cursor:grab;
width:var(--pr-thumb);height:var(--pr-thumb);margin-top:calc((var(--pr-h) - var(--pr-thumb)) / 2);
border:2px solid var(--pr-accent);border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(15,23,42,.3)}
.pr-slider .form-range::-moz-range-thumb{box-sizing:border-box;pointer-events:auto;cursor:grab;
width:var(--pr-thumb);height:var(--pr-thumb);border:2px solid var(--pr-accent);border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(15,23,42,.3)}
.pr-slider .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pr-accent)}
.pr-slider .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pr-accent)}
/* min / max boxes under the track: visible <label>s for the two inputs */
.pr-boxes{margin-top:4px}
.pr-box{gap:2px;border:1px solid #8e98a8;border-radius:12px}
.pr-box .form-label{font-size:.75rem;color:#5d6472}
.pr-box output{font-size:1rem;font-weight:700;color:#1e293b;font-variant-numeric:tabular-nums}
.pr-dash{color:#5d6472}
/* histogram: bars inside the selected range get the accent (the script sets data-in) */
.pr-bars{display:flex;align-items:flex-end;gap:3px;height:64px;margin:6px calc(var(--pr-thumb) / 2) -12px}
.pr-bars span{flex:1;border-radius:3px 3px 0 0;background:#d9dde4}
.pr-bars span[data-in]{background:#fb7185}
.pr.solid .pr-slider .form-range::-webkit-slider-thumb{border:3px solid #fff;background:var(--pr-accent)}
.pr.solid .pr-slider .form-range::-moz-range-thumb{border:3px solid #fff;background:var(--pr-accent)}
/* time window: pill thumbs and an hour scale */
.pr.time{--pr-thumb:16px;--pr-h:4px}
.pr.time .pr-slider{height:26px}
.pr.time .pr-slider .form-range::-webkit-slider-thumb{height:26px;margin-top:-11px;border-radius:8px;border:0;background:var(--pr-accent)}
.pr.time .pr-slider .form-range::-moz-range-thumb{height:26px;border-radius:8px;border:0;background:var(--pr-accent)}
.pr-scale{margin:-4px calc(var(--pr-thumb) / 2) 0;font-size:.78rem;color:#5d6472;font-variant-numeric:tabular-nums}
.pr-scale span{width:0;display:flex;justify-content:center;white-space:nowrap}
JavaScript
// Two-thumb sliders: keeps min <= max, paints the selected range (--lo, --hi) and updates the readouts.
document.querySelectorAll('.pr').forEach(function (group) {
var ranges = group.querySelectorAll('.form-range'), lo = ranges[0], hi = ranges[1];
var min = +lo.min, max = +lo.max, bars = group.querySelectorAll('.pr-bars span');
function fmt(v) {
v = +v;
if (group.dataset.format === 'time') return String(Math.floor(v / 60)).padStart(2, '0') + ':' + String(v % 60).padStart(2, '0');
return '$' + v.toLocaleString('en-US');
}
function sync(e) {
if (+lo.value > +hi.value) { if (e && e.target === lo) lo.value = hi.value; else hi.value = lo.value; }
var a = (lo.value - min) / (max - min), b = (hi.value - min) / (max - min);
group.style.setProperty('--lo', a);
group.style.setProperty('--hi', b);
lo.style.zIndex = (a === b && a > 0.5) ? 2 : ''; // when the thumbs meet on the right, keep the min thumb on top
[lo, hi].forEach(function (r) {
var out = group.querySelector('output[for="' + r.id + '"]');
r.setAttribute('aria-valuetext', fmt(r.value));
if (out) out.textContent = fmt(r.value);
});
var sum = group.querySelector('.pr-sum');
if (sum) sum.textContent = fmt(lo.value) + ' – ' + fmt(hi.value);
bars.forEach(function (bar, i) { var c = (i + 0.5) / bars.length; bar.toggleAttribute('data-in', c >= a && c <= b); });
}
lo.addEventListener('input', sync);
hi.addEventListener('input', sync);
sync();
});
// Two-thumb sliders: keeps min <= max, paints the selected range (--lo, --hi) and updates the readouts.
document.querySelectorAll('.pr').forEach(function (group) {
var ranges = group.querySelectorAll('input[type="range"]'), lo = ranges[0], hi = ranges[1];
var min = +lo.min, max = +lo.max, bars = group.querySelectorAll('.pr-bars span');
function fmt(v) {
v = +v;
if (group.dataset.format === 'time') return String(Math.floor(v / 60)).padStart(2, '0') + ':' + String(v % 60).padStart(2, '0');
return '$' + v.toLocaleString('en-US');
}
function sync(e) {
if (+lo.value > +hi.value) { if (e && e.target === lo) lo.value = hi.value; else hi.value = lo.value; }
var a = (lo.value - min) / (max - min), b = (hi.value - min) / (max - min);
group.style.setProperty('--lo', a);
group.style.setProperty('--hi', b);
lo.style.zIndex = (a === b && a > 0.5) ? 2 : ''; // when the thumbs meet on the right, keep the min thumb on top
[lo, hi].forEach(function (r) {
var out = group.querySelector('output[for="' + r.id + '"]');
r.setAttribute('aria-valuetext', fmt(r.value));
if (out) out.textContent = fmt(r.value);
});
var sum = group.querySelector('.pr-sum');
if (sum) sum.textContent = fmt(lo.value) + ' – ' + fmt(hi.value);
bars.forEach(function (bar, i) { var c = (i + 0.5) / bars.length; bar.toggleAttribute('data-in', c >= a && c <= b); });
}
lo.addEventListener('input', sync);
hi.addEventListener('input', sync);
sync();
});
About this set
Two-thumb range sliders for filters. Price range with min and max boxes is the shop and booking pattern: a nightly price from $0 to $1,000 with the selected span in the header and a labelled Minimum and Maximum box under the track. With a price histogram adds 24 bars above a home price slider, and the bars inside the chosen range turn from grey to rose so buyers see how many listings they are keeping. Time window swaps prices for a departure window in 30 minute steps with pill thumbs and an hour scale. Each one is two real range inputs stacked on one track: the inputs ignore the pointer and only their thumbs take it, so either thumb can be dragged, clicked into and moved with the keyboard on its own. About twenty lines of JavaScript keep the minimum from passing the maximum, lift the minimum thumb above the other when they meet on the right so it can still be dragged, paint the stretch between them through –lo and –hi, and write readable values such as 06:30 into the outputs and aria-valuetext.
What’s included
Two real range inputs sharing one track
Script keeps min at or below max and handles thumbs that meet
Min and max boxes with visible labels for each input
Histogram whose bars light up inside the selected range
Time window formatted as hours and minutes
Group labelled with role="group" and aria-labelledby
Accessibility
Each pair sits in a role="group" named by its visible heading. The two inputs are named Minimum and Maximum by visible labels or by aria-label, and each announces its own formatted value, such as $180 or 06:30, through aria-valuetext. Both thumbs are in the tab order, respond to the arrow keys and show a ring when focused.
Customise it
Change --pr-accent, --pr-track, --pr-thumb and --pr-h on .pr (plain CSS and Bootstrap) or the --color-pr-* theme colours in Tailwind. The histogram heights are inline percentages on the bars, and data-format="time" on the group switches the readout to hours and minutes.