Multi-Range Price Slider — Free HTML CSS JS Snippet

Multi-Range Price Slider · Forms · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Percentage-based position math
(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.
Document-level drag listeners
mousemove/mouseup attach to document on drag start, not the handle — prevents losing the cursor when moving faster than the element boundary.
Min/max mutual guard (10-unit gap)
minVal = Math.min(v, maxVal - 10) prevents handles crossing or coinciding, keeping the fill track and ARIA values always valid.
ARIA role="slider" compliance
Handles have role="slider", aria-valuemin, aria-valuemax, and aria-valuenow that update on every drag move — fully usable with screen readers.
Keyboard arrow key support
Arrow keys adjust value by 10 units; Shift+Arrow adjusts by 50. Both handles respond independently, with full accessibility compliance.
Live product filter
The product list below the slider re-renders on every value change, filtering to items within the price range and showing a count badge.
Floating handle tooltips
Each handle shows a price tooltip above it on hover and while dragging — CSS opacity transition, no JavaScript required for show/hide.
Touch support
Separate 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

Screenshot of the Multi-Range Price Slider snippet rendered live

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:

text
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

  1. 1
    Paste 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.
  2. 2
    Drag 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.
  3. 3
    Drag 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.
  4. 4
    Use 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.
  5. 5
    Test on touch devicesTouch-drag either handle on a phone or tablet. Touch events are handled separately from mouse events to support both simultaneously.
  6. 6
    Connect your product dataReplace the PRODUCTS array in JS with your real data. The filter logic is price >= minVal && price <= maxVal — adapt the field name to match your data shape.

Real-world uses

Common Use Cases

E-commerce price filter
The canonical use case — filter products by price range in a shop sidebar. Connect to URL params (?min=100&max=500) for shareable filtered searches.
Job listing salary filter
Filter job listings by salary range. Adapt the domain from price to salary values and update the tick labels to show salary increments (e.g., $30k–$200k).
Date range selector
Map the domain to timestamps and format the display values as dates. Each handle represents a start/end date — useful for filtering events, bookings, or reports.
Property rental price filter
Real estate and rental platforms (Airbnb, Booking.com pattern) use dual-handle sliders as their primary price filter. The live count shows available listings.
Audio/video range trimmer
Map the domain to a video duration in seconds. The two handles define the trim start and end points. Pair with a progress bar for the playhead.
Settings with bounded numeric ranges
App settings that require two related values (min/max volume, temperature comfort range, notification quiet hours) benefit from a dual-handle slider over two separate inputs.

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.