ui-031

Dual Range Slider

£10 or the whole Vault, £99

A dual range slider for price filters: two thumbs that cannot cross, a histogram that lights bar by bar, a rolling result count and typed min/max.

Draggable intermediate
8 more details
micro-interactiondraggabletouch-dragsnapcount-upstaggerform-animationkeyboard-navigation

This element reads better at your own screen size than in the frame below:

Updated

About this component

A two-handled price and filter range in the shape shoppers know from travel and property search. A histogram of your data sits on the track and grows in on load; take hold of either thumb and the bars inside the range light up one at a time as it passes them, the selected segment stretches to follow, a bubble counts to the number, and a result count such as "986 homes" rolls to the items now in range. Two typed fields under the track stay in sync both ways: type a price and the thumb glides there, validated, clamped and never past the other thumb.

Read the full effect overview

Two real range inputs stay in the DOM behind the drawn control, so the pair submits with an ordinary form, reads correctly to a screen reader and moves with the arrow keys. It mounts with RangeSlider.mount(el, options) and returns setValues, getValues, setHistogram and destroy, with range:input and range:change events. Light and dark themes ship as one design at two sets of token values.

Dual Range Slider - UI component preview

What's included

9 items
  • Two Draggable thumbs on one track, bounded so they cannot cross
  • Histogram from raw values or bucket counts, growing in with a stagger on mount
  • Bars in range light up one at a time as a thumb passes them
  • Result count rolls to the number of items in range, in your own wording
  • Typed min and max fields synced both ways, validated and clamped
  • Value bubbles lift clear of the finger and count to their number
  • Hidden native range inputs keep the values for forms and the keyboard
  • Mount API with setValues, getValues, setHistogram, destroy and range:input / range:change events
  • Light and dark themes from one data-variant attribute, every colour a custom property

Perfect for

4 use cases
  • Price filters on shop, travel and property listings, with the price distribution shown
  • Salary, distance and duration windows on search interfaces
  • Numeric bounds in dashboard and report filter panels
  • Any pair of values where how many results fall between them is the answer

How it works

3 sections

The inputs hold the values

Two native range inputs supply the min, max and step, keep the values for form submission, and provide the keyboard path. Dragging and typing write into them behind a guard flag, and the component fires range:input continuously and range:change on commit.

Bars that follow the thumb

The fill and the histogram are painted from where each thumb is drawn, every frame of a drag or a tween. A bar is in range when its centre lies between the thumbs and toggles a class only when that changes, so a gliding thumb lights the bars one by one.

A count from the data

With raw values the result count is exact; with bucket counts each bar contributes its overlapping share. The visible number rolls through a tweened proxy, while a live region announces the settled result once per commit.

Plugins Draggable
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Dual Range Slider opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • Two Draggable thumbs on one track, bounded so they cannot cross

  • A histogram built from raw item values or from bucket counts, bars growing in with a short stagger on mount

  • Bars inside the range take the accent and the rest fade to a muted tone, changing one bar at a time as a thumb moves, including during a tweened jump

  • A result count ("986 homes", in your own wording) that rolls to the number of items in range

  • Min and max text fields: typing moves the thumb with a tween, input is validated, clamped to the scale and stopped at the other thumb

  • Value bubbles that lift clear of the finger and count to their number

  • Two hidden native range inputs keep the values for forms and the keyboard

  • A mount API with setValues, getValues, setHistogram and destroy, plus range:input and range:change events

  • Plain stacked native sliders when JavaScript does not run

  • Light and dark themes from one data-variant attribute

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add to your <body>:

Code snippet omitted: it ships with the download.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

The histogram, the count and the fields are all optional: leave any of them out of the markup (and the data out of the options) and the slider works without it.

Mounting without JavaScript options

Any .range with a bare data-range attribute (not "manual") is mounted automatically on DOMContentLoaded, so a slider configured entirely in markup needs no script of your own:

Code snippet omitted: it ships with the download.

data-range="manual" skips the auto-mount, so your own RangeSlider.mount(el, options) call is the only one.

API

RangeSlider.mount(element, options)

Returns an instance, or null if the required markup is missing. Mounting an element that is already mounted with new options destroys the old instance first; with no options it returns the existing one. RangeSlider.get(element) returns the instance for an element, or null.

Option Type Default Description
min / max / step number the native inputs' attributes The scale. When given, they are written onto both native inputs so the keyboard agrees
values [a, b] the native inputs' value Starting pair, snapped to the step and put in order
data number[] none Raw item values (one per listing, product, row). Bucketed into bins bars and counted exactly. Values outside the scale are counted at its nearest end, so a top bar reads as "600 and over"
bins number 40 Bar count when bucketing data
histogram number[] data-histogram Ready-made bucket counts, one per bar, spread evenly across the scale. The count is then estimated from each bar's overlap with the range
format (value) => string data-prefix + value + data-suffix Text for the bubbles and the readout
formatCount (n) => string data-count-format, else n.toLocaleString() Text for the result count
formatField (value) => string the plain number Text written into the typed fields

Instance

Member Description
setValues([a, b], { animate }) Moves both thumbs (tweened unless animate: false), updates the fields, bubbles, bars and count, and fires range:input and range:change if anything changed. Pass null for an end to leave it. The pair is sorted, so it never crosses
getValues() [min, max]
setHistogram(counts) / setHistogram({ data, bins }) Replaces the data. Same bar count: the bars re-grow to their new heights. Different: the histogram is rebuilt and grows in. The count rolls to the new total
getCount() The current in-range count, or null with no data
element The root element
destroy() Kills the Draggables, reverts every GSAP set and tween, removes listeners, the built bars and the live region, and restores the inline styles the markup had

Events

Both bubble from the root element. event.detail is { values: [min, max], count, source }, where source is drag, keyboard, field or api.

Event Fires
range:input Continuously: every step while dragging, every arrow key, each typed commit
range:change On commit only: pointer release, a keyboard change, a typed value committed with Enter or blur, a setValues call. Not fired when the values end where they started

The native inputs still dispatch their own input and change events too, so a listener already on your form keeps working.

Markup hooks

Hook On Required Description
data-range root yes Auto-mounts; data-range="manual" waits for RangeSlider.mount
data-range-track, data-range-fill, data-thumb="min"/"max" spans yes The drawn control
data-input="min"/"max" input[type=range] yes Hold the values, the scale and the keyboard path
data-bubble="min"/"max" spans no Value bubbles
data-range-histogram div no Where the bars are built; created before the track if missing and there is data
data-range-count any element no The rolling result count
data-range-field="min"/"max" text inputs no The typed fields
data-range-read any element no A plain "£40 – £320" readout
data-prefix, data-suffix, data-histogram, data-count-format root no Markup-only configuration, see above

Themes

The slider ships in two themes, light (the default) and dark. They are one design at two sets of token values. Pick one with an attribute on <body>, or on any wrapper around the slider in your own page:

Code snippet omitted: it ships with the download.

Nothing else changes: same markup, same script, and nothing in assets/script.js ever reads the theme name. The bars' colours are CSS classes on tokens, so a theme switch repaints them without JavaScript. The demo's toolbar toggle and the ?variant=dark URL parameter only set that attribute, and dispatch resize so the geometry re-measures.

Every colour, radius and shadow is a custom property in the two body[data-variant="..."] blocks at the top of assets/style.css. To wear your own brand, re-value that one short list:

  • Ground and text: --ground, --ground-2, --raised, --hover, --ink, --ink-2, --ink-3, --line, --line-strong, --shadow, --font, --radius, --radius-sm
  • Accent: --accent, --accent-ink, --accent-wash (the accent fills the selected segment, the bars in range and the focus ring)
  • Slider: --track-bg, --track-h, --track-radius, --fill-on, --thumb-size, --thumb-bg, --thumb-border, --thumb-radius, --thumb-shadow, --bubble-bg, --bubble-fg, --bubble-border, --bubble-radius, --read-fg, --focus
  • Histogram and fields: --hist-h, --bar-gap, --bar-radius, --bar-on, --bar-off, --field-h, --field-bg, --danger (the invalid field border)

For ink-coloured bars instead of accent ones, set --bar-on: var(--ink-2).

How It Works

The inputs hold the values. The two native ranges supply min, max and step, keep the values for form submission, and provide the keyboard path. Dragging and typing write into them and dispatch input and change behind a guard flag, so nothing loops.

Bounds that narrow on press. Each thumb is a Draggable of type: 'x'. On press, applyBounds is called with the other thumb's current position as the limit, so the two can meet but never swap. liveSnap rounds x to whole steps in pixels, and the release tween settles exactly on the snapped value.

Bars that follow the thumb, not the value. The fill and the bars are painted from where each thumb is drawn (gsap.getProperty(thumb, 'x')), on every drag frame and every frame of a tween. A bar is in range when its centre lies between the thumbs, and it only toggles its .is-in class when that changes, so a thumb gliding across the histogram after a typed value or a reset lights the bars one at a time, each with a short CSS colour transition. Mount grows the bars from scaleY: 0 with a stagger spread across all of them.

A count from the data. With raw data, the count is exact: every value between the thumbs. With bucket counts only, each bar contributes its share by how much of it the range overlaps. The visible number is tweened through a proxy and hidden from assistive tech; a visually hidden live region is updated with the settled result on commit, so a screen reader hears "986 homes" once, not every frame.

Typed fields. Each field commits on Enter or blur. The text is parsed with anything that is not a digit, a point or a minus removed, so "£1,250" works. Unparseable text reverts; a number is snapped to the step, clamped to the scale and stopped at the other thumb, and the thumb tweens there. While typing, a value that would be clamped marks the field aria-invalid and draws its border in --danger.

Bubbles that count. Each bubble's number comes from a proxy tweened with gsap.to, so a jump reads as counting. The bubble lifts RISE pixels on press with back.out(2.4) and returns with power2.out.

Using It With Your Own Design

The non-obvious CSS the component depends on:

  • The thumbs and the fill must carry no CSS transform. GSAP owns their x (and an xPercent: -50 that centres each thumb on its value). A CSS translateX underneath is parsed as a pixel x that never clears.
  • Both thumbs and the fill are left: 0. Their position is x measured from the track's left edge, so any CSS left offset would double the travel.
  • .range-body carries padding: 0 calc(var(--thumb-size) / 2), and the histogram sits inside it, so the bars span exactly the track's width and a bar's centre lines up with the value under it.
  • .has-js is added by the script. Without it the drawn track, histogram, count and fields are hidden and the two native sliders become visible, stacked and styled with accent-color, so the page still filters.
  • The thumbs are touch-action: pan-y, which lets a vertical swipe that starts on a thumb scroll the page.

Demo furniture

The demo page's toolbar strip (the Reset button and the Light/Dark toggle) and the centred .stage are the demo, not part of what you bought. The nightly prices are a seeded list generated in index.html's inline script, passed in through RangeSlider.mount exactly as your own data would be. Delete the toolbar and the stage in your own project; the component is the .range block.

Customisation

  • Colours, radii and sizes live in assets/style.css as custom properties per theme; the accent is #2563eb in light and a lifted #4d86f7 in dark.
  • MOVE, COUNT, ROLL, GROW, RISE and BINS at the top of assets/script.js are the travel time for a keyboard or typed change, the bubble count duration, the result count roll, the histogram grow-in spread, the bubble lift and the default bar count. Set RISE to 0 for bubbles that stay put.
  • A single step settles with back.out(1.6); a jump of more than three steps glides in on power3.out, so a long sweep across the bars does not overshoot.
  • format in the options handles a "£600+" top end or a locale-aware currency: format: (v) => '£' + v + (v >= 600 ? '+' : '').

Keyboard and Accessibility

  • The slider is a pair of native <input type="range"> elements, so Tab reaches them and the arrow keys, Page Up, Page Down, Home and End all work. Each carries an aria-label naming which end it controls; the drawn thumbs, bubbles and histogram are aria-hidden so nothing is announced twice.
  • The typed fields are ordinary text inputs with inputmode="decimal" and their own labels. Enter commits, ArrowUp and ArrowDown step the value (Shift for ten steps), and an out-of-range value is flagged aria-invalid until it is committed and corrected.
  • The result count is announced once per commit through a polite live region.
  • Focusing a range input draws a ring on its thumb and lifts its bubble, using :focus-visible so the ring does not appear on a mouse press.
  • Neither the keyboard nor a typed value can cross the thumbs: a value that would pass the other one stops at it.
  • prefers-reduced-motion: reduce places the thumbs, numbers and bars instantly, skips the grow-in and the bubble lift, and shows the count settled. Dragging still works.

Requirements

  • GSAP 3.12+ and the Draggable plugin
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.