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">
<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>
/*
* 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 subscribes to the range-slider change event and reflects the selected range
// into a readout. The library provides behavior only, so the value display is the
// consumer's (this script's) job.
document.querySelectorAll('[data-controller~="stimeo--range-slider"]').forEach((root) => {
const output = root.querySelector('[data-range-slider-output]');
if (!output) return;
root.addEventListener('stimeo--range-slider:change', (e) => {
output.textContent = `${e.detail.start} – ${e.detail.end}`;
});
});
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" |
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); kept ≤ end. | data-stimeo--range-slider-start-value |
end
|
Finite upper thumb value (default 100); kept ≥ start. | 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 |
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. |