GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Dual Range Slider UI — 9 Two-Handle Patterns

Among nine dual range slider patterns, one blocks its two handles from crossing, one merges their value bubbles when close, and one draws a bar chart behind

Auto-plays · click a tile to jump to its section · all nine in one zip

This set puts nine two-handle sliders in one row: a handle-crossing clamp, a between-handles fill, a tooltip-merge pair, a shift-both drag, a keyboard turn-taking pair, a histogram background, a step-10 snap, a spring-elastic gap, and a reduced-motion comparison. Getting a dual range slider html right takes more than two <input type="range"> elements wired together — every pattern below adds exactly one calculation layer on top of the same pointer-drag and keyboard baseline, and that layer is a small, named function you can read start to finish in the code section rather than pulled from a slider library.

Demo

Range Slider Whose Two Handles Never Cross
Open in a new tab

01 · Range Slider Whose Two Handles Never Cross — Measured area 3.058% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAP=14role=slideraria-valuetext

Pattern 01 above needs nothing beyond the clamp inside setLo/setHi to keep its two handles apart. Scroll for the remaining eight — each one sits right above its own code block.

Anatomy of nine two-handle sliders — where the one layer of calculation runs

# Pattern Cumulative area Avg. intensity Frames moved
01 Handle-crossing clamp 3.058% 176.2 22 of 23
02 Between-handles fill 3.797% 101.0 21 of 23
03 Tooltip-merge pair 8.158% 111.0 21 of 23
04 Shift-both drag 4.107% 55.4 17 of 23
05 Keyboard turn-taking 3.223% 145.1 21 of 23
06 Histogram background 6.338% 87.0 21 of 23
07 Step-10 snap 5.147% 129.7 21 of 23
08 Min-gap elastic push 3.027% 159.9 21 of 23
09 Reduced-motion compare 5.484% 129.3 21 of 23

Pattern 03 covers the most pixels of the nine (8.158% cumulative) since merging two bubbles into one repaints both value labels and the merged label together, while pattern 08 covers the least (3.027%) because its spring only nudges each thumb a few pixels per frame instead of redrawing a bubble or a bar. Pattern 01 has the sharpest average intensity (176.2) and moves in the most frames (22 of 23) of any pattern here, since every single pointer step re-clamps and can flip the is-blocked class as well as move the thumb. Pattern 04 sits at the opposite end on both counts — the lowest average intensity (55.4) and the fewest frames moved (17 of 23) — because dragging the filled middle only ever repaints two thumbs and one fill block, and the drag stops the instant either handle reaches an end.

Group the nine by what the one extra calculation actually reads, and three families show up.

Handle-relationship constraints

Patterns 01, 04, and 08 all police the space between the two handles rather than each handle's own position. 01's setLo/setHi clamp the incoming value against the other handle's value minus or plus GAP. 04's shiftBoth keeps the width (hi - lo) fixed while moving both endpoints by the same delta. 08 goes further and pushes the other handle's target when the requested gap would fall under MIN_GAP, then lets a spring animate the visual catch-up.

getBoundingClientRect-derived readouts

Patterns 02, 03, and 07 all convert a percentage into a real pixel value by reading the track's actual rendered width. 02 reports the fill's pixel width next to its percentage. 03 measures the pixel gap between the two value bubbles and compares it to MERGE_PX before deciding to merge them. 07 recomputes every tick's pixel left from the track's measured width, so the ticks stay lined up if the track's width ever changes.

State computed once, not on every pointer move

Patterns 05, 06, and 09 hold a piece of state that a drag never touches. 05 tracks which handle is "active" from focusin/focusout, independent of any pointer event. 06 builds its histogram bars once from a fixed HEIGHTS array and only re-checks which bars fall inside [lo, hi] on each paint. 09 keeps one shared value pair and draws it into two panels — a transitioning one and a transition-less one — so neither panel's paint depends on the other.

Code per pattern

Every block below is the exact function the pattern's own index.html runs — same variable names, same order, nothing shortened.

01Range Slider Whose Two Handles Never Cross

Pushing the low handle toward the high one stops it right at a set minimum gap, so it never overtakes or touches the other handle. Use it for a filter that sets a minimum and maximum price at once.

Range Slider Whose Two Handles Never Cross
Open in a new tab

01 · Range Slider Whose Two Handles Never Cross — Measured area 3.058% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAP=14role=slideraria-valuetext
  // Calculation layer: clamps the low value so it can never exceed high - GAP, and clamps the high value so it can never drop below low + GAP
  function setLo(n) {
    var blocked = document.getElementById('track');
    v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
    lo.classList.toggle('is-blocked', n > v.hi - GAP);
    paint();
  }
  function setHi(n) {
    v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
    hi.classList.toggle('is-blocked', n < v.lo + GAP);
    paint();
  }

02Range Slider That Fills Only Between Its Two Handles

Instead of the whole track, only the stretch between the low and high handle is filled, and each end of that fill follows its handle as it moves — a different demo from 224: 224 (range-slider-dual-thumb-fill) only updates the labels and fills the track, while this one adds a getBoundingClientRect-measured pixel readout of the fill width plus an autoplay sweep phase. Use it for a stats filter where the chosen span needs to stand out at a glance.

Range Slider That Fills Only Between Its Two Handles
Open in a new tab

02 · Range Slider That Fills Only Between Its Two Handles — Measured area 3.797% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

getBoundingClientRectpxOutaria-valuetext
  // Calculation layer: turns the two values into a fill percentage, then reads the track's real width with getBoundingClientRect to convert that fill into a pixel count too
  function paint() {
    lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
    fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
    var sweepPct = v.lo + (v.hi - v.lo) * v.phase;
    var trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
    sweep.style.left = trackPct + '%';
    lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
    lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
    loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
    var r = track.getBoundingClientRect();
    pxOut.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
  }

03Range Slider Whose Two Tooltips Merge When Values Get Close

A value bubble follows each handle, and once the two values draw near each other the bubbles combine into one, listing both values side by side instead of overlapping. Use it for a booking-date filter for narrowing in on a tight span precisely.

Range Slider Whose Two Tooltips Merge When Values Get Close
Open in a new tab

03 · Range Slider Whose Two Tooltips Merge When Values Get Close — Measured area 8.158% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

MERGE_PX=46getBoundingClientRectrole=slider
  // Calculation layer: measures the two value bubbles' real on-screen gap in pixels from the track's measured width, and merges them into one once that gap is narrower than MERGE_PX
  function paint() {
    lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
    fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
    bLo.style.left = v.lo + '%'; bHi.style.left = v.hi + '%';
    bMerged.style.left = (v.lo + v.hi) / 2 + '%';
    lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
    lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
    bLo.textContent = String(v.lo); bHi.textContent = String(v.hi);
    bMerged.textContent = v.lo + ' – ' + v.hi;
    var r = track.getBoundingClientRect();
    var gapPx = r.width * (v.hi - v.lo) / 100;
    var merged = gapPx < MERGE_PX;
    root.classList.toggle('is-merged', merged);
  }

04Range Slider Whose Middle Drag Shifts Both Handles Together

Grabbing the middle of the filled stretch moves both handles together while keeping the gap between them fixed, stopping the moment either handle reaches the track's end. Use it for a schedule filter that shifts a chosen span forward or back while keeping its width.

Range Slider Whose Middle Drag Shifts Both Handles Together
Open in a new tab

04 · Range Slider Whose Middle Drag Shifts Both Handles Together — Measured area 4.107% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

shiftBoth()getBoundingClientRectrole=slider
  // Calculation layer: keeps the width (hi - lo) fixed while shifting both values by the same delta, stopping in place the instant either one reaches the track's end
  function shiftBoth(deltaPct) {
    var w = v.hi - v.lo;
    var nlo = v.lo + deltaPct;
    nlo = Math.max(MIN, Math.min(nlo, MAX - w));
    v.lo = nlo; v.hi = nlo + w;
    paint();
  }

05Range Slider Whose Two Handles Take Turns on Tab

Pressing Tab moves focus back and forth between the low and high handle, and the arrow keys nudge only the focused handle one step at a time while the minimum gap still holds. Use it for a form filter that sets exact values without a mouse.

Range Slider Whose Two Handles Take Turns on Tab
Open in a new tab

05 · Range Slider Whose Two Handles Take Turns on Tab — Measured area 3.223% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

focusinaria-live=politerole=slider
  // Calculation layer: tracks which handle currently has focus via focusin/focusout, so the arrow keys move only that handle
  function setActive(which) {
    root.classList.toggle('is-active-lo', which === 'lo');
    root.classList.toggle('is-active-hi', which === 'hi');
    status.textContent = which === 'lo' ? 'Tab · Minimum value focused' : 'Tab · Maximum value focused';
  }
  lo.addEventListener('focusin', function () { setActive('lo'); });
  hi.addEventListener('focusin', function () { setActive('hi'); });

06Range Slider That Picks a Span Over a Histogram Background

A bar chart broken into value buckets sits right behind the track, so the bar heights show where the span picked by the handles falls within the overall distribution. Use it for a search filter that shows listing counts across price buckets alongside the range.

Range Slider That Picks a Span Over a Histogram Background
Open in a new tab

06 · Range Slider That Picks a Span Over a Histogram Background — Measured area 6.338% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

HEIGHTS.forEach()role=slideraria-valuenow
  HEIGHTS.forEach(function (h, i) {
    var bar = document.createElement('div');
    bar.className = 'ds__bar';
    bar.style.left = (i / HEIGHTS.length * 100) + '%';
    bar.style.width = (100 / HEIGHTS.length * 0.7) + '%';
    bar.style.height = h + '%';
    hist.appendChild(bar);
    bars.push({ el: bar, center: (i + 0.5) / HEIGHTS.length * 100 });
  });

  // Calculation layer: decides whether to highlight a bar by checking if its real center position (%) falls inside the [lo, hi] span
  function paintBars() {
    bars.forEach(function (b) {
      b.el.classList.toggle('is-in', b.center >= v.lo && b.center <= v.hi);
    });
  }

07Range Slider That Snaps to Ticks in Steps of 10

The moment a handle moves it rounds to the nearest tick in steps of 10, and the low and high values are continuously compared so neither can cross the other. It's a good min max range slider html reference for a quantity filter that only accepts values in fixed steps.

Range Slider That Snaps to Ticks in Steps of 10
Open in a new tab

07 · Range Slider That Snaps to Ticks in Steps of 10 — Measured area 5.147% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP=10getBoundingClientRectrole=slider
  // Calculation layer: places each tick not by percentage but by recalculating its position from the track's measured pixel width (getBoundingClientRect)
  function layoutTicks() {
    var r = track.getBoundingClientRect();
    ticks.forEach(function (t) {
      var x = r.width * (t.value - MIN) / (MAX - MIN);
      t.el.style.left = Math.round(x) + 'px';
    });
  }
  function snap(n) { return Math.max(MIN, Math.min(MAX, Math.round(n / STEP) * STEP)); }

08Range Slider That Springs to Keep Its Minimum Gap

If the gap between the two handles would shrink past its minimum, the opposite handle's target is pushed along to preserve it, and each handle's on-screen position chases its target with spring physics, overshooting slightly as it settles. Use it for a headcount range filter where the gap must never get too narrow.

Range Slider That Springs to Keep Its Minimum Gap
Open in a new tab

08 · Range Slider That Springs to Keep Its Minimum Gap — Measured area 3.027% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

MIN_GAP=12requestAnimationFramerole=slider
  function springFrame(ts) {
    if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
    if (last === null) last = ts;
    var dt = Math.min(0.032, (ts - last) / 1000); last = ts;
    ['lo', 'hi'].forEach(function (k) {
      var s = pos[k], t = target[k];
      var force = -K * (s.v - t) - DAMP * s.vel;
      s.vel += force * dt; s.v += s.vel * dt;
    });
    paintVisual();
    requestAnimationFrame(springFrame);
  }

09Range Slider Comparing Reduced Motion Against Normal Motion Side by Side

The same pair of handle values is drawn into two panels at once — the left panel transitions to its new position while the right-hand preview jumps instantly with no transition, so the reduced-motion result sits side by side with the normal one. Use it as an accessibility fallback preview for motion-sensitive users.

Range Slider Comparing Reduced Motion Against Normal Motion Side by Side
Open in a new tab

09 · Range Slider Comparing Reduced Motion Against Normal Motion Side by Side — Measured area 5.484% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-hidden=trueprefers-reduced-motionrole=slider
  // Calculation layer: draws one shared state into two panels at once — the right-hand preview has
  // no transition at all, so it always shows the instant-jump result a reduced-motion user would see (a comparison independent of the real OS setting)
  function paint() {
    lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
    fillA.style.left = v.lo + '%'; fillA.style.width = (v.hi - v.lo) + '%';
    loB.style.left = v.lo + '%'; hiB.style.left = v.hi + '%';
    fillB.style.left = v.lo + '%'; fillB.style.width = (v.hi - v.lo) + '%';
    lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
    lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
    loOutA.textContent = String(v.lo); hiOutA.textContent = String(v.hi);
    loOutB.textContent = String(v.lo); hiOutB.textContent = String(v.hi);
  }

Where values change the UI — minimum gap, snap-tick spacing, tooltip-merge distance

01 — GAP

This constant sets the minimum distance setLo and setHi are always kept apart by — not how far a drag can travel, just the floor neither handle is ever allowed to cross below.

07 — STEP

This is the increment snap() rounds every handle value to, in Math.round(n / STEP) * STEP — not the tick line's own pixel width, just how coarse or fine the values a visitor can land on are.

03 — MERGE_PX

This is the on-screen pixel gap paint() compares against before it toggles the two value bubbles into one — not the values themselves, just the distance under which the bubbles read as a single label instead of two.

Variant Changed value What it feels like
GAP 14 → 6 Minimum handle gap shrinks from 14 to 6 The two handles can sit almost on top of each other before the clamp kicks in, so a nearly zero-width span becomes possible
STEP 10 → 25 Snap increment grows from 10 to 25 Handles jump between far fewer stops, so dragging feels coarse and deliberate instead of continuous
MERGE_PX 46 → 18 Merge threshold shrinks from 46px to 18px The two value bubbles stay separate almost until they visually touch, instead of merging while there's still daylight between them

Where it breaks — accessibility, reduced-motion, overlapping handles

All nine patterns share the same baseline: each handle is a real <button role="slider"> with aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext kept in sync on every paint, an aria-label reading "Minimum value" or "Maximum value" (pulled from the same phrase file the demo itself uses), and tabindex="0" so Tab reaches it in normal document order — Home and End jump a handle to its own boundary, and the arrow keys nudge it by one unit. matchMedia('(prefers-reduced-motion: reduce)') gates the same thing in every pattern: the autoplay loop that drives the demo's presets on its own. Each pattern's SCSS then drops the left/width transition specifically while that autoplay state is active, so a visitor who has reduced motion set sees the presets change with no visible motion the moment the page loads — dragging or pressing an arrow key afterward still transitions normally in eight of the nine, since a value someone chose on purpose isn't the motion the setting is meant to suppress.

  • 01 — only the thumb and fill lose their transition, and only while the autoplay presets are cycling.
  • 02 — the thumb, the fill, and the autoplay sweep marker all lose their transition together.
  • 03 — the thumb, the fill, and the merge bubble lose their transition together, so the merge itself doesn't animate under reduced motion either.
  • 04 — the thumb and fill lose their transition, so shiftBoth's parallel move during autoplay happens instantly instead of gliding.
  • 05 — the thumb and fill lose their transition; the role="status" announcement that reports which handle is focused is untouched either way, since text isn't a CSS transition.
  • 06 — the thumb, the fill, and the histogram bar highlight all lose their transition together.
  • 07 — the thumb and fill lose their transition; the tick positions were never animated in the first place, since layoutTicks() only recomputes them once and again on resize.
  • 08 — the one exception: the rule isn't scoped to the autoplay state at all, so the transition is off unconditionally, because syncReduced() also snaps a dragged or keyboard-nudged handle straight to its target instead of letting the spring loop overshoot.
  • 09 — this pattern's entire point is showing the difference side by side, so nothing is conditionally removed: the right panel is always transition-less and the left panel always transitions, letting a visitor compare both without touching their OS setting.

Reading through the zip end to end, unlocked with the password nrfh573p, is the fastest way to see exactly which one CSS rule each pattern trims for reduced motion, since the nine style sheets sit side by side inside it.

Three variants and accessibility

The table above already lists the three confirmed variants — GAP, STEP, and MERGE_PX — each swapping exactly one constant while every listener, clamp, and ARIA attribute stays the same as the base pattern it came from. Because each variant is the same index.html as its base pattern with only that one constant edited, the ARIA attributes, the keyboard handling, and the matchMedia reduced-motion gate documented above apply to all three unchanged — nothing in the accessibility layer needs to be re-verified per variant.

Where can I find dual range slider codepen examples?

Most codepen searches for a dual range slider turn up either a single <input type="range"> styled to look like two, or a heavyweight plugin pulled in from a CDN. The nine patterns above are the opposite of both: plain buttons, plain pointer and keyboard listeners, and one small calculation function apiece, all sitting in the zip above ready to paste into a form. A price range slider ui is the single most common real-world use for this shape, but the same clamp-and-paint structure underneath works just as well for a date span, a headcount range, or any other paired minimum-and-maximum input. None of the nine reach for a slider library or a CDN script to get there — the whole point of keeping each pattern to one small named function is that a dual range slider html snippet like this reads start to finish in the code section above, instead of asking you to trust an opaque bundle.

FAQ

Why does pattern 07 recompute its tick positions on resize but pattern 01 doesn't need to?

07's ticks are placed in pixels via layoutTicks(), which reads the track's getBoundingClientRect() width — so if that width changes on resize, the old pixel positions would drift off their labels unless they're recalculated. 01's clamp works entirely in the same 0–100 percentage scale the handles already use, so it never has to know the track's pixel width at all.

Does pattern 08's spring overshoot ever let the gap go under MIN_GAP?

No — the committed target.lo/target.hi values are clamped by MIN_GAP before the spring ever runs, so the underlying data never violates the minimum gap. The overshoot only affects pos.lo.v/pos.hi.v, the on-screen position the spring is chasing that target with, so what a viewer sees can lag or bounce past the target for a frame or two without the real values ever crossing the floor.

Is a min max range slider html built this way safe to drop into a real form?

The clamp, keyboard, and ARIA logic transfer directly — swap the demo's autoplay preset timer for whatever real min/max state your form already tracks, and the rest of each function needs no changes. The one thing every pattern here skips on purpose is a real name/value pair for form submission, so add that binding yourself before shipping it.

Sources: MDN — role="slider" · MDN — Element.getBoundingClientRect() · MDN — prefers-reduced-motion · MDN — requestAnimationFrame() · MDN — Window.matchMedia()

Related reading: CSS Range Slider: 9 Physics-Grade Custom Sliders · 9 Date Range Picker UI Patterns — Copy-Paste · 9 Filter UI Patterns (Panel Slide to Chip Remove)

Enter the archive password

The password is inside this article. You will find it as you read.