On this page
Range group
Two
<input type="range">
stacked on one track inside
<fieldset class="form-range-group">.
Load
src/behaviors/range-group.js
for clamp and fill. It is not a
pe-*
element. Markup follows the Open UI
<rangegroup>
proposal, so a future native wrapper is a rename.
| What runs it | JavaScript | |
|---|---|---|
| Bootstrap 5.3 | No range-group plugin. Dual sliders are a third-party widget or custom ARIA sliders. | Not in bootstrap.min.js |
| This repo | Two `.form-range` in `.form-range-group`. Optional `src/behaviors/range-group.js` for clamp and fill. | 0 KB. Optional src/behaviors/range-group.js |
| Avoid |
|
|---|
How it works
-
The
<legend>names the group. Each thumb has its own<label for>. Both inputs sharemin,max, andstepso they sit on the same scale. -
CSS stacks the inputs in
.form-range-track. The track is not a click target. Only the thumbs receive pointer events. -
The module listens to
inputand clamps a thumb against its neighbours in DOM order. Overlap is allowed. It does not rewriteminormaxon the inputs. -
It writes
--bs-range-lowerand--bs-range-upperon the fieldset so CSS can paint the fill. There is no CSS path from a live range value. -
An optional
<output for aria-live="off">is a visible readout. The slider already announces the value, so the output is not a live region. -
Without the script the inputs still work. Thumbs can cross, and
there is no fill.
data-range-group="off"on the fieldset skips the module when it is on the page. The CSS stack stays.
Price
Drag either thumb. They stop at each other. The fill and the numbers follow.
<script type="module" src="./node_modules/bootstrap-html/src/behaviors/range-group.js"></script>
<fieldset class="form-range-group">
<legend class="form-label">Price</legend>
<label class="visually-hidden" for="price-min">Minimum price</label>
<label class="visually-hidden" for="price-max">Maximum price</label>
<div class="form-range-track">
<input type="range" class="form-range" id="price-min" name="price-min" min="0" max="1000" step="10" value="200">
<input type="range" class="form-range" id="price-max" name="price-max" min="0" max="1000" step="10" value="800">
</div>
<p class="form-text mb-0">
<output for="price-min" aria-live="off">200</output> – <output for="price-max" aria-live="off">800</output>
</p>
</fieldset> No script
Same stacking. data-range-group="off" skips clamp and fill when the module is on the page. Thumbs can pass, and the track has no fill.
<fieldset class="form-range-group" data-range-group="off">
<legend class="form-label">Year</legend>
<label class="visually-hidden" for="year-min">Earliest year</label>
<label class="visually-hidden" for="year-max">Latest year</label>
<div class="form-range-track">
<input type="range" class="form-range" id="year-min" name="year-min" min="1900" max="2026" step="1" value="1980">
<input type="range" class="form-range" id="year-max" name="year-max" min="1900" max="2026" step="1" value="2010">
</div>
</fieldset> Accessibility
Each thumb is a native slider. Arrow keys, Home, End, and Page Up / Page Down come from the browser. Tab order is DOM order and does not change when thumbs overlap.
Bootstrap’s focus ring is on the thumb, so each stacked input still shows which handle is focused.
Name the group with <legend>. Name each thumb with <label for>. Do not use aria-labelledby on a value readout.
The readout is <output for aria-live="off">. Focus is on the slider, which already announces the value; a live region would speak it twice. That is the opposite of Spin button, where focus stays on the button.
Thumbs are 1.5rem (24px) because the track is not a pointer target. That meets WCAG 2.5.8 Target Size (Minimum).
Browser support
| No script | Polyfill | No fallback |
|---|---|---|
Two native type="range" on one track. Keyboard, submit, and focus work. Without range-group.js thumbs can cross and there is no fill. data-range-group="off" skips the module when it is loaded. | None | The track is not a click target: pointer-events: none on the inputs, so only the thumbs receive pointer events. The later thumb in the DOM wins when they overlap. Fill needs range-group.js. |