Range Slider
stimeo--range-slider
A slider with two handles for the lower and upper end of a range, which never cross.
Picks the lower and upper end of a range with two handles, and the lower one never passes the upper. How far each handle can currently travel is announced as it changes, so the keyboard alone is enough. Both positions are readable from CSS as fractions, so drawing the handles and the selected span is yours. The minimum, maximum, step and starting positions come from your markup. It brings no styling.
Keyboard
| Key | Action |
|---|---|
| → / ↑ | Increase the focused thumb by one step (never past the other thumb). With logicalTrack under RTL the horizontal pair swaps. |
| ← / ↓ | Decrease the focused thumb by one step (never past the other thumb). With logicalTrack under RTL the horizontal pair swaps. |
| PageUp / PageDown | Move the focused thumb by ten steps. |
| Home / End | Jump the focused thumb to its movable minimum / maximum. |
<%# Markup for the range-slider (APG Slider, multi-thumb) demo.
stimeo--range-slider handles the two thumbs' (start / end) mutual constraint
(lower ≤ upper), keyboard / drag operation, and dynamic updates of each thumb's
aria-valuemin/max/now, and exposes the range as the root CSS variables
--stimeo--range-slider-start / --stimeo--range-slider-end (0..1). The thumb
and range placement is the Playground's CSS using those variables. %>
<div
class="range-slider"
data-controller="stimeo--range-slider"
data-stimeo--range-slider-min-value="0"
data-stimeo--range-slider-max-value="100"
data-stimeo--range-slider-step-value="1"
data-stimeo--range-slider-start-value="20"
data-stimeo--range-slider-end-value="80">
<%# The hidden fields carry the range into the form; both are optional. %>
<input type="hidden" name="price_min" data-stimeo--range-slider-target="startField" />
<input type="hidden" name="price_max" data-stimeo--range-slider-target="endField" />
<div class="range-slider__track" data-stimeo--range-slider-target="track"
data-action="pointerdown->stimeo--range-slider#onPointerDown">
<div class="range-slider__range" aria-hidden="true"></div>
<div class="range-slider__thumb" role="slider" tabindex="0"
aria-label="<%= t("components.range_slider.demo.min_label") %>"
data-stimeo--range-slider-target="startThumb"
data-action="keydown->stimeo--range-slider#onKeydown"></div>
<div class="range-slider__thumb" role="slider" tabindex="0"
aria-label="<%= t("components.range_slider.demo.max_label") %>"
data-stimeo--range-slider-target="endThumb"
data-action="keydown->stimeo--range-slider#onKeydown"></div>
</div>
<output class="range-slider__output" data-range-slider-output aria-live="polite">20 – 80</output>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* Presentation-only styles for the range-slider demo.
* The thumb and range positions are computed from the CSS variables the library
* exposes on the root: --stimeo--range-slider-start /
* --stimeo--range-slider-end (0..1).
*/
.range-slider {
width: min(22rem, 100%);
padding: 0.75rem 0.625rem;
}
.range-slider__track {
position: relative;
height: 0.375rem;
border-radius: 999px;
background: var(--border);
}
/* Fill from start to end with the accent color. */
.range-slider__range {
position: absolute;
top: 0;
height: 100%;
left: calc(var(--stimeo--range-slider-start, 0) * 100%);
width: calc(
(var(--stimeo--range-slider-end, 1) - var(--stimeo--range-slider-start, 0)) * 100%
);
border-radius: 999px;
background: var(--accent);
}
/* Place each thumb at its fraction position (translateX(-50%) to center it). */
.range-slider__thumb {
position: absolute;
top: 50%;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--surface-card);
border: 2px solid var(--accent);
transform: translate(-50%, -50%);
cursor: grab;
}
.range-slider__thumb[data-stimeo--range-slider-target="startThumb"] {
left: calc(var(--stimeo--range-slider-start, 0) * 100%);
}
.range-slider__thumb[data-stimeo--range-slider-target="endThumb"] {
left: calc(var(--stimeo--range-slider-end, 1) * 100%);
}
.range-slider__thumb:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.range-slider__output {
display: block;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--text-muted, var(--color-text-muted));
}
// Demo that reflects the selected range into a readout. The library provides behavior
// only, so the value display is the consumer's (this script's) job. `change` carries
// the user's moves and `reconcile` a pair the page moved (a runtime `min` / `max` /
// `step` / `start` / `end` change); both carry the same `{ start, end }` detail.
document.querySelectorAll('[data-controller~="stimeo--range-slider"]').forEach((root) => {
const output = root.querySelector('[data-range-slider-output]');
if (!output) return;
const showRange = (e) => {
output.textContent = `${e.detail.start} – ${e.detail.end}`;
};
root.addEventListener('stimeo--range-slider:change', showRange);
root.addEventListener('stimeo--range-slider:reconcile', showRange);
});
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a pair the page moved as
// `stimeo--range-slider:reconcile`. This demo reads both events, so the readout
// follows the user's moves and the page's.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSelector = [
"[data-stimeo--range-slider-target='startField']",
"[data-stimeo--range-slider-target='endField']",
].join(", ");
const fieldSources = demoStage.querySelectorAll(fieldSelector);
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll(fieldSelector))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--range-slider:reconcile", showSubmitted);
showSubmitted();
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--range-slider"
Targets
| Name | Description | Attribute |
|---|---|---|
track
required
|
The rail; a pointerdown on it moves the nearest thumb and starts a drag. | data-stimeo--range-slider-target="track" |
startThumb
required
|
The lower (minimum) thumb (role=slider); capped by the end thumb. |
data-stimeo--range-slider-target="startThumb" |
endThumb
required
|
The upper (maximum) thumb (role=slider); capped by the start thumb. |
data-stimeo--range-slider-target="endThumb" |
startField
|
Optional hidden input the start is mirrored into. |
data-stimeo--range-slider-target="startField" |
endField
|
Optional hidden input the end is mirrored into. |
data-stimeo--range-slider-target="endField" |
Values
| Name | Description | Attribute |
|---|---|---|
min
|
Finite lowest value of the range (default 0). | data-stimeo--range-slider-min-value |
max
|
Finite highest value of the range (default 100); must be at least min. | data-stimeo--range-slider-max-value |
step
|
Snap/step increment (default 1); arrows move by it, Page by 10×. | data-stimeo--range-slider-step-value |
start
|
Finite lower thumb value (default 0). The published pair keeps it ≤ end; the value itself is left as written until the user moves a thumb. | data-stimeo--range-slider-start-value |
end
|
Finite upper thumb value (default 100). The published pair keeps it ≥ start; the value itself is left as written until the user moves a thumb. | data-stimeo--range-slider-end-value |
logicalTrack
|
Whether the track is laid out with logical CSS and mirrors under RTL (default false). When set, the pointer mapping and the horizontal arrow pair follow the writing direction; left unset nothing reads direction. | data-stimeo--range-slider-logical-track-value |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Keyboard stepping for the focused thumb (Arrows, Page, Home/End), never crossing. | stimeo--range-slider#onKeydown |
onPointerDown
|
Starts a track drag, moving the nearest thumb. An overlapped pair expands toward the press. | stimeo--range-slider#onPointerDown |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched on user-driven changes, with start and end in detail. |
stimeo--range-slider:change |
reconcile
|
Fires when the page moves the published pair (a runtime min / max / step / start / end change or a morph), once per batch; same detail as change. Not fired on connect. |
stimeo--range-slider:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-valuenow |
Each thumb | That thumb's current value. |
aria-valuemin / aria-valuemax |
Each thumb | The movable range, dynamically limited by the other thumb. |
--stimeo--range-slider-start / --stimeo--range-slider-end |
Root element | Each value as a 0..1 fraction, used to position the thumbs and range. |