noUiSlider Dual-Handle Price Range Filter — Free JS Snippet
noUiSlider Dual-Handle Price Range Filter · Forms · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="pr-wrap">
<div class="pr-panel">
<div class="pr-head"><h3>Price</h3><span id="prSummary">$40 – $360</span></div>
<div class="pr-hist" id="prHist" aria-hidden="true"></div>
<div id="prSlider"></div>
<div class="pr-inputs">
<label>Min <input type="number" id="prMin" min="0" max="500" step="5"></label>
<label>Max <input type="number" id="prMax" min="0" max="500" step="5"></label>
<button type="button" id="prReset">Reset</button>
</div>
</div>
<div class="pr-results">
<p class="pr-count" id="prCount" aria-live="polite"></p>
<ul class="pr-grid" id="prGrid"></ul>
</div>
</div>body { background: #f5f7fa; padding: 22px; font-family: system-ui, sans-serif; }
.pr-wrap { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: 250px 1fr; gap: 18px; align-items: start; }
@media (max-width: 620px) { .pr-wrap { grid-template-columns: 1fr; } }
.pr-panel { background: #fff; border: 1px solid #e3e8ef; border-radius: 14px; padding: 18px 18px 16px; }
.pr-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.pr-head h3 { margin: 0; font-size: 15px; color: #111827; }
.pr-head span { font-size: 13px; font-weight: 700; color: #0f766e; font-variant-numeric: tabular-nums; }
.pr-hist { display: flex; align-items: flex-end; gap: 3px; height: 44px; margin: 0 8px 14px; }
.pr-hist i { flex: 1; background: #d1d9e3; border-radius: 3px 3px 0 0; min-height: 3px; transition: background .15s; }
.pr-hist i.on { background: #14b8a6; }
#prSlider { margin: 0 8px 26px; height: 6px; border: 0; background: #e5e9ef; box-shadow: none; }
#prSlider .noUi-connect { background: #14b8a6; }
#prSlider .noUi-handle { width: 22px; height: 22px; right: -11px; top: -8px; border-radius: 50%; border: 3px solid #14b8a6; background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,.18); cursor: grab; }
#prSlider .noUi-handle::before, #prSlider .noUi-handle::after { display: none; }
#prSlider .noUi-handle:focus-visible { outline: 3px solid rgba(20,184,166,.35); outline-offset: 2px; }
#prSlider .noUi-horizontal .noUi-tooltip, #prSlider.noUi-horizontal .noUi-tooltip { bottom: auto; top: 150%; }
#prSlider .noUi-tooltip { font-size: 11px; font-weight: 700; color: #0f766e; border: 0; background: #e6fbf8; padding: 2px 6px; border-radius: 6px; }
.pr-inputs { display: flex; gap: 8px; align-items: flex-end; }
.pr-inputs label { flex: 1; font-size: 11px; font-weight: 700; color: #5b6579; text-transform: uppercase; letter-spacing: .04em; }
.pr-inputs input { width: 100%; margin-top: 4px; padding: 7px 8px; border: 1px solid #cfd6e2; border-radius: 8px; font: inherit; font-size: 14px; color: #111827; }
.pr-inputs input:focus { outline: 2px solid #14b8a6; outline-offset: 0; border-color: #14b8a6; }
.pr-inputs button { font: inherit; font-size: 12px; font-weight: 700; color: #0f766e; background: #e6fbf8; border: 0; border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.pr-count { margin: 4px 0 12px; font-size: 13px; font-weight: 600; color: #4b5563; }
.pr-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.pr-item { background: #fff; border: 1px solid #e3e8ef; border-radius: 12px; overflow: hidden; transition: opacity .2s, transform .2s; }
.pr-item.out { opacity: .18; transform: scale(.97); }
.pr-swatch { height: 64px; }
.pr-item div { padding: 10px 12px 12px; }
.pr-item b { display: block; font-size: 13px; color: #111827; }
.pr-item span { font-size: 13px; color: #0f766e; font-weight: 700; font-variant-numeric: tabular-nums; }const PRODUCTS = [
['Canvas Tote', 28, '#fde68a'], ['Desk Lamp', 45, '#fecaca'], ['Ceramic Mug Set', 36, '#bfdbfe'],
['Wool Throw', 89, '#ddd6fe'], ['Backpack', 120, '#a7f3d0'], ['Bluetooth Speaker', 149, '#fbcfe8'],
['Standing Mat', 65, '#fed7aa'], ['Espresso Maker', 210, '#c7d2fe'], ['Noise-Cancel Buds', 249, '#bae6fd'],
['Mechanical Keyboard', 175, '#d9f99d'], ['Monitor Arm', 98, '#fecdd3'], ['Leather Chair', 420, '#e9d5ff'],
['Studio Monitor', 330, '#99f6e4'], ['Travel Duffel', 78, '#fef08a'], ['Notebook Pack', 18, '#e5e7eb'],
['Smart Thermostat', 190, '#c4b5fd'],
];
const MIN = 0, MAX = 500, BINS = 20;
const slider = document.getElementById('prSlider');
const minIn = document.getElementById('prMin');
const maxIn = document.getElementById('prMax');
const summary = document.getElementById('prSummary');
const countEl = document.getElementById('prCount');
const grid = document.getElementById('prGrid');
const hist = document.getElementById('prHist');
// Build the cards once and toggle a class — re-creating the DOM on every drag tick would flicker.
grid.innerHTML = PRODUCTS.map(function (p) {
return '<li class="pr-item" data-price="' + p[1] + '"><div class="pr-swatch" style="background:' + p[2] + '"></div><div><b>' + p[0] + '</b><span>$' + p[1] + '</span></div></li>';
}).join('');
const cards = Array.prototype.slice.call(grid.children);
// Price histogram behind the handles.
const binSize = (MAX - MIN) / BINS;
const bins = new Array(BINS).fill(0);
PRODUCTS.forEach(function (p) { bins[Math.min(BINS - 1, Math.floor((p[1] - MIN) / binSize))]++; });
const peak = Math.max.apply(null, bins);
hist.innerHTML = bins.map(function (n) { return '<i style="height:' + Math.max(6, (n / peak) * 100) + '%"></i>'; }).join('');
const bars = Array.prototype.slice.call(hist.children);
noUiSlider.create(slider, {
start: [40, 360],
connect: true,
step: 5,
margin: 10, // handles can never be closer than $10
range: { min: MIN, max: MAX },
tooltips: [true, true],
format: { to: function (v) { return '$' + Math.round(v); }, from: function (v) { return Number(String(v).replace('$', '')); } },
ariaFormat: { to: function (v) { return Math.round(v) + ' dollars'; }, from: Number },
});
function apply(lo, hi) {
summary.textContent = '$' + lo + ' – $' + hi;
minIn.value = lo; maxIn.value = hi;
let shown = 0;
cards.forEach(function (c) {
const price = Number(c.dataset.price);
const inRange = price >= lo && price <= hi;
c.classList.toggle('out', !inRange);
if (inRange) shown++;
});
bars.forEach(function (b, i) {
const start = MIN + i * binSize;
b.classList.toggle('on', start + binSize > lo && start < hi);
});
countEl.textContent = shown + ' of ' + PRODUCTS.length + ' products match';
}
// 'update' fires on every move; unencoded gives real numbers, not the formatted "$40" strings.
slider.noUiSlider.on('update', function (values, handle, unencoded) {
apply(Math.round(unencoded[0]), Math.round(unencoded[1]));
});
// Typed values drive the slider through set(); null leaves the other handle alone.
minIn.addEventListener('change', function () { slider.noUiSlider.set([minIn.value, null]); });
maxIn.addEventListener('change', function () { slider.noUiSlider.set([null, maxIn.value]); });
document.getElementById('prReset').addEventListener('click', function () { slider.noUiSlider.set([MIN, MAX]); });Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
noUiSlider Dual-Handle Price Range Filter — HTML, CSS & JavaScript

Two handles on one track is the standard interface for filtering by price, but two native range inputs overlapped on top of each other is one of the most fragile hacks in front-end work — the handles cross, the fill needs manual maths and keyboard focus goes to whichever input is on top. noUiSlider was designed for exactly this problem: one slider, two handles, a connecting bar between them, with the crossing rules and step snapping handled inside the library.
This snippet builds a complete filter around it. The slider runs from 0 to 500 in steps of 5, with margin: 10 guaranteeing the handles can never sit closer than ten dollars apart. Tooltips show each handle's value, and the format option controls how they render — a to() function that adds the dollar sign and a from() function that strips it back off, so the library can parse typed input. ariaFormat does the same job for screen readers, announcing "40 dollars" instead of a bare number.
The important detail is which value the update listener uses. The first argument to update is an array of already formatted strings like "$40", which are for display. The third argument, unencoded, holds the raw numbers. The filter reads those, so there is no string parsing in the hot path. update fires continuously while dragging, so the product cards are built once and only a class is toggled on each tick; rebuilding the grid on every event would flicker and drop frames.
A price histogram sits behind the handles. Products are bucketed into twenty bins, and bars whose range overlaps the selection light up, which tells users at a glance where the products actually are — a range filter without that feedback invites selecting a range that matches nothing. The two number inputs are synced both ways: dragging updates them, and typing calls slider.noUiSlider.set([value, null]), where null leaves the other handle untouched.
Step by step
How to Use
- 1Drag a handleDrag either handle. The bar, the tooltip, the histogram and the product grid all update together.
- 2Watch the histogramBars inside your range turn teal, showing how many products live at each price point.
- 3Type exact valuesEnter a number in Min or Max. The slider moves to match and the other handle stays where it was.
- 4Try to cross the handlesDrag one handle towards the other — the ten-dollar margin stops them colliding.
- 5ResetPress Reset to restore the full 0 – 500 range and show every product.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Overlapping range inputs cannot cross safely and need manual fill maths. noUiSlider gives one component with connected handles, margins, snapping and keyboard support built in.
values are strings run through your format.to() function for display. unencoded are the raw numbers, which are what filtering logic should use.
Set the margin option. Here margin: 10 keeps them at least ten dollars apart.
Call slider.noUiSlider.set([min, null]) or set([null, max]). null leaves that handle unchanged.
noUiSlider handles are focusable and respond to arrow keys. Add an ariaFormat so spoken values include the unit.
Yes. Provide extra keys in range, such as "50%": [100, 10], to give the lower prices more track length.
Yes. Use the JSX, Vue, Angular or Tailwind export buttons on this page to convert the markup and styles. The behaviour comes from noUiSlider, so in a framework project install it with npm install nouislider instead of the CDN tag, create it in useEffect / onMounted / ngAfterViewInit on the element, and release it with noUiSlider destroy() when the component unmounts.