More Forms Snippets
Multi-Range Price Slider — Free HTML CSS JS Snippet
Multi-Range Price Slider · Forms · Plain HTML, CSS & JS · Live preview
What's included
Features
(value - MIN) / (MAX - MIN) * 100 maps domain values to CSS left percentages. The fill track left and width derive directly from the two handle positions.mousemove/mouseup attach to document on drag start, not the handle — prevents losing the cursor when moving faster than the element boundary.minVal = Math.min(v, maxVal - 10) prevents handles crossing or coinciding, keeping the fill track and ARIA values always valid.role="slider", aria-valuemin, aria-valuemax, and aria-valuenow that update on every drag move — fully usable with screen readers.touchstart/touchmove/touchend handlers mirror the mouse events with e.touches[0].clientX — works on mobile without conflicts.About this UI Snippet
Multi-Range Slider — Dual Handle Position Math, Live Product Filter & Pointer/Touch Events

A dual-handle range slider for price filtering is one of the most common interactive controls in e-commerce and search interfaces — yet it is notoriously tricky to implement correctly. This snippet builds a production-quality multi-range slider from scratch: two draggable handles with precise percentage-based positioning math, an animated fill track that updates in real time, keyboard arrow key support, full touch event handling, ARIA role and value attributes, and a live product list that filters to show only items within the selected range.
The dual-handle slider pattern appears everywhere — Airbnb's price filter, Amazon's price range, Booking.com's review score slider, job boards filtering by salary. The challenge is that the two handles must be aware of each other (min handle cannot exceed max and vice versa), the fill track must connect exactly between the two handles, and the whole thing must work with mouse, touch, and keyboard.
Handle position as percentage math
Each handle's position is stored as a value in the [MIN, MAX] domain (0–1000 for price). Converting to a CSS left percentage is (value - MIN) / (MAX - MIN) * 100 — a standard linear interpolation. The fill track's left equals the min handle percentage and its width equals the difference between max and min percentages. All three DOM updates (left handle, right handle, fill) run in a single render() function that also updates the price display boxes and tooltips — one source of truth, one render pass.
Drag event handling without global pointer lock
Each handle's mousedown / touchstart attaches mousemove/mouseup listeners to the document (not the handle element) and removes them on mouseup. This prevents the handle from "losing" the cursor if the pointer moves fast — a common bug in slider implementations. The position within the track is computed from (clientX - trackRect.left) / trackRect.width — a ratio clamped to [0, 1] before conversion to a domain value.
Mutual constraint: min/max guard
On every drag move, a 10-unit gap is enforced: minVal = Math.min(value, maxVal - 10) and maxVal = Math.max(value, minVal + 10). This prevents the handles from crossing or overlapping, which would make the fill track invert and the ARIA values nonsensical.
Keyboard accessibility
Each handle is a role="slider" button with tabindex="0", aria-valuemin, aria-valuemax, and aria-valuenow attributes kept in sync with the current values. Arrow key listeners move the value by 10 units (or 50 with Shift), making the slider fully operable without a pointer. Pair with a search box for complete product filtering UI.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Instead of tracing the drag math by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why makeDraggable attaches mousemove and mouseup to the document rather than the handle element, and what visible bug would appear during a fast drag if it attached them to the handle instead. The same assistant can help optimize it, for example checking whether recomputing the full filtered product list and rebuilding its DOM on every single pixel of drag movement is excessive compared to throttling the render, or whether the 10-unit minimum gap between handles should scale with the overall MIN to MAX range. It's also useful for extending the slider: ask it to sync minVal and maxVal to the URL query string so filtered views are shareable links, add a histogram of product density behind the track, or support typing an exact number directly into the price display boxes. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "dual-handle range slider" price filter in plain HTML, CSS, and JavaScript using only pointer/touch events and percentage-based positioning math — no slider library.
Requirements:
- A track with two circular draggable handles, a gradient fill segment between them, and a numeric domain (e.g. 0 to 1000) with fixed MIN and MAX constants that all positioning math derives from.
- Each handle's on-screen position must be computed as a percentage: (value - MIN) / (MAX - MIN) * 100, applied as a CSS left percentage; the fill segment's left and width must derive directly from the two handle percentages so it always spans exactly between them.
- Dragging a handle must compute the new value from the pointer's horizontal position relative to the track's bounding rectangle (clamped to the track's edges), rounding to a whole unit, and must enforce a minimum gap (e.g. 10 units) so the two handles can never cross or land on the same value.
- Drag listeners (mousemove/mouseup and touchmove/touchend) must be attached to the document when a drag starts and removed when it ends, not attached permanently to the handle, so a fast drag that outruns the handle's bounding box doesn't lose tracking.
- Each handle must be a real, keyboard-operable slider (role="slider", tabindex, aria-valuemin/max/now kept live) where arrow keys move it by a normal step and Shift+arrow moves it by a larger step, still respecting the minimum gap and domain bounds.
- Below the slider, filter and render a fixed product list to only items whose price falls within the current [min, max] range, updating a visible count badge, every time either handle moves — whether by drag or keyboard.
- Show a floating tooltip above each handle with its current formatted value, visible on hover and continuously while dragging.Step by step
How to Use
- 1Paste HTML, CSS, and JSA price filter card appears with two draggable handles on a gradient track, price display boxes, tick labels, and a live product list below.
- 2Drag the left handle (min)The minimum price updates in real time. The fill track shrinks/expands from the left. A tooltip shows the current value above the handle while dragging.
- 3Drag the right handle (max)The maximum price updates. The product list below instantly filters to show only items within the selected price range, with a count badge.
- 4Use keyboard navigationTab to either handle and use arrow keys to adjust. Left/Right arrows move by $10. Hold Shift for $50 increments. ARIA values update for screen readers.
- 5Test on touch devicesTouch-drag either handle on a phone or tablet. Touch events are handled separately from mouse events to support both simultaneously.
- 6Connect your product dataReplace the
PRODUCTSarray in JS with your real data. The filter logic isprice >= minVal && price <= maxVal— adapt the field name to match your data shape.
Real-world uses
Common Use Cases
?min=100&max=500) for shareable filtered searches.Got questions?
Frequently Asked Questions
Change the MIN and MAX constants at the top of the JS. Update the tick labels in HTML to match. The percentage math is relative to these constants, so all handle positions, fill, and keyboard steps scale automatically.
Remove the Math.round() around the value calculation and set the step in the keyboard handler to 0.5. Update the display format: '$' + v.toFixed(2).
Store [minVal, maxVal] as state with useState([120, 480]). The drag handlers update state via setMinVal/setMaxVal. Use useRef for the track element to compute getBoundingClientRect(). The product list renders from a useMemo that filters the PRODUCTS array on every state change.
On every render, call history.replaceState({}, '', ?min=${minVal}&max=${maxVal}). On page load, read the params: const params = new URLSearchParams(location.search); minVal = +params.get('min') || MIN.