Range Slider
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/range-sliderPlain-CSS theme — no build step
npx astrocraft-ui add forms/range-slider --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/range-slider --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/range-slider --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/range-slider --theme tailwind --bridge lumosLive demo
RangeSlider
Two native range inputs on one rail. Drag the low thumb past the high one and it STOPS at it — the far thumb is never pushed, because that would change a value you were not touching. The output echoes the pair for the eye only; the sliders announce themselves.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `RangeSlider.astro` | `range-slider` `range-slider-input` | `data-size`: `sm` · `md` | `data-low` `data-high` | — |
| `SliderMarks.astro` | `slider-mark` `slider-marks` `slider-marks-labels` `slider-marks-list` | — | — | — |
| `SliderOutput.astro` | `slider-output` | `data-bound`: `low` · `high` · `range` | — | — |
| `SliderThumb.astro` | `slider-thumb` | `data-bound`: `low` · `high` | — | — |
| `SliderTrack.astro` | `slider-range` `slider-track` | — | — | — |
Source
What the command copies — 7 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/range-slider/RangeSlider.astro — headless primitive (see ../../README.md).
// A two-thumb range: two real <input type="range">s over one track. Each thumb is therefore a real
// slider — its own keyboard, its own label, its own announced value, its own focus ring — and the
// only thing this file adds is the rule neither input can know on its own: they must not cross
// (`clampPair` in `range-pair.ts`, checked by `range-pair.test.ts`).
//
// Unstyled it is two ordinary sliders stacked in the flow, and it works. A theme overlays them on
// one rail; SliderTrack, SliderThumb, SliderMarks and SliderOutput are the optional parts it paints,
// and the script keeps their positions and text truthful.
//
// Like Slider, this ships no `appearance: none` — that declaration erases the UA's control in WebKit,
// so a library that shipped it without geometry would hand consumers an invisible slider. See
// `theme-default.css` for the worked example of restyling a range, which is all-or-nothing.
//
// ponytail: two thumbs, one track, no vertical orientation (`writing-mode: vertical-lr` on a range
// input is the platform's answer and needs no code here). The values submit as `${name}-min` and
// `${name}-max`, matching DateRangePicker.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
min?: number;
max?: number;
step?: number;
low?: number;
high?: number;
/** The two fields submit as `${name}-min` and `${name}-max`. */
name?: string;
/** Names the pair, so they are announced as one control. */
label?: string;
lowLabel?: string;
highLabel?: string;
/** The smallest gap the thumbs may be left with. */
minDistance?: number;
size?: "sm" | "md";
};
const {
min = 0,
max = 100,
step = 1,
low = min,
high = max,
name,
label = "Range",
lowLabel = "Minimum",
highLabel = "Maximum",
minDistance = 0,
size = "md",
class: className,
...rest
} = Astro.props;
---
<div
role="group"
aria-label={label}
data-slot="range-slider"
data-min-distance={minDistance}
data-size={size}
class={className}
{...rest}
>
<input
type="range"
min={min}
max={max}
step={step}
value={low}
name={name && `${name}-min`}
aria-label={lowLabel}
data-slot="range-slider-input"
data-bound="low"
/>
<input
type="range"
min={min}
max={max}
step={step}
value={high}
name={name && `${name}-max`}
aria-label={highLabel}
data-slot="range-slider-input"
data-bound="high"
/>
<slot />
</div>
<script>
import { onReadyOnce } from "../../_once";
import { clampPair, percent } from "./range-pair";
function wire(root: HTMLElement) {
const inputs = [...root.querySelectorAll<HTMLInputElement>('[data-slot="range-slider-input"]')];
const lowInput = inputs.find((input) => input.dataset.bound === "low");
const highInput = inputs.find((input) => input.dataset.bound === "high");
if (!lowInput || !highInput) return;
const fill = root.querySelector<HTMLElement>('[data-slot="slider-range"]');
const thumbs = [...root.querySelectorAll<HTMLElement>('[data-slot="slider-thumb"]')];
const outputs = [...root.querySelectorAll<HTMLElement>('[data-slot="slider-output"]')];
const marks = root.querySelector<HTMLElement>('[data-slot="slider-marks-list"]');
// The ticks are wired here rather than passed as a prop: SliderMarks is slotted, so it cannot
// hand its generated id upwards, and `list` is the one attribute that makes the browser draw it.
if (marks?.id) for (const input of inputs) input.setAttribute("list", marks.id);
const bounds = {
min: Number(lowInput.min || 0),
max: Number(lowInput.max || 100),
minDistance: Number(root.dataset.minDistance) || 0,
};
const settle = (moved: "low" | "high") => {
const pair = clampPair(
{ low: lowInput.valueAsNumber, high: highInput.valueAsNumber },
moved,
bounds,
);
// Only write back a value that actually changed: assigning `.value` mid-drag interrupts the
// native drag in some browsers, and re-writing the same number every pointermove is that.
if (lowInput.valueAsNumber !== pair.low) lowInput.value = String(pair.low);
if (highInput.valueAsNumber !== pair.high) highInput.value = String(pair.high);
const from = percent(pair.low, bounds.min, bounds.max);
const to = percent(pair.high, bounds.min, bounds.max);
root.dataset.low = String(pair.low);
root.dataset.high = String(pair.high);
if (fill) {
fill.style.left = `${from}%`;
fill.style.width = `${to - from}%`;
}
for (const thumb of thumbs) {
thumb.style.left = `${thumb.dataset.bound === "high" ? to : from}%`;
}
for (const output of outputs) {
const bound = output.dataset.bound;
output.textContent =
bound === "low"
? String(pair.low)
: bound === "high"
? String(pair.high)
: `${pair.low}${output.dataset.separator ?? " – "}${pair.high}`;
}
};
for (const input of inputs) {
const bound = input.dataset.bound === "high" ? "high" : "low";
input.addEventListener("input", () => settle(bound));
input.addEventListener("change", () => settle(bound));
}
// WHICH THUMB A PRESS BELONGS TO. A theme stacks the two inputs on one rail, and then whichever
// is on top takes every press — so the other thumb is draggable only with a keyboard, which is a
// bug no test catches and no screenshot shows. Raising the input whose thumb is nearer the
// pointer, as the pointer moves, is what keeps both live. It is inert until a theme overlays
// them, because `z-index` does nothing to a statically positioned box.
//
// (The CSS-only version of this — `pointer-events: none` on the input and `auto` on
// `::-webkit-slider-thumb` — does not work in current Chrome: the input stops handling drags
// altogether and the pseudo-element cannot hand them back.)
//
// ponytail: it follows HOVER, so on a touchscreen the first drag can grab whichever thumb was
// raised last and the second gets it right. Fixing that exactly means owning the drag outright
// and re-implementing what the native control already does well for a pointer.
root.addEventListener("pointermove", (event) => {
const box = root.getBoundingClientRect();
if (box.width === 0) return;
const at = ((event.clientX - box.left) / box.width) * 100;
const toLow = Math.abs(at - percent(lowInput.valueAsNumber, bounds.min, bounds.max));
const toHigh = Math.abs(at - percent(highInput.valueAsNumber, bounds.min, bounds.max));
lowInput.style.zIndex = toLow <= toHigh ? "2" : "1";
highInput.style.zIndex = toLow <= toHigh ? "1" : "2";
});
settle("low");
}
onReadyOnce('[data-slot="range-slider"]', wire);
</script>
---
// src/components/ui/forms/range-slider/SliderMarks.astro — RangeSlider compound part (see ../../README.md).
// Tick marks, as a native <datalist>. RangeSlider's script points both of its inputs at it with the
// `list` attribute, so the browser draws its own ticks — Chrome, Edge and Safari all do — and snaps
// to them where it supports snapping. No geometry of ours, no measurement, no script beyond wiring
// one attribute.
//
// The visible labels beside it are separate and `aria-hidden`, because the datalist is already
// announced with the slider and repeating each number would double every announcement.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
/** The values to mark. Labels default to the numbers themselves. */
values: readonly (number | { value: number; label: string })[];
};
const { values, class: className, ...rest } = Astro.props;
const marks = values.map((mark) =>
typeof mark === "number" ? { value: mark, label: String(mark) } : mark,
);
const listId = `slider-marks-${crypto.randomUUID().slice(0, 8)}`;
---
<div data-slot="slider-marks" class={className} {...rest}>
<datalist id={listId} data-slot="slider-marks-list">
{marks.map((mark) => <option value={mark.value} label={mark.label} />)}
</datalist>
<div aria-hidden="true" data-slot="slider-marks-labels">
{
marks.map((mark) => (
<span data-slot="slider-mark" data-value={mark.value}>
{mark.label}
</span>
))
}
</div>
</div>
---
// src/components/ui/forms/range-slider/SliderOutput.astro — RangeSlider compound part (see ../../README.md).
// The current value in text, as a native <output> — the element HTML has for "the result of a
// calculation on this form", which is exactly what a slider readout is.
//
// `aria-hidden`, and that is deliberate rather than an oversight: an <output> is a live region by
// default, and the range inputs already announce their own values as you move them. Left audible it
// would say every number twice. It is here for the eye; the sliders are here for everyone.
//
// `bound` picks what it echoes: one end, or the whole range with `separator` between.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"output"> & {
bound?: "low" | "high" | "range";
separator?: string;
};
const { bound = "range", separator = " – ", class: className, ...rest } = Astro.props;
---
<output
aria-hidden="true"
data-slot="slider-output"
data-bound={bound}
data-separator={separator}
class={className}
{...rest}></output>
---
// src/components/ui/forms/range-slider/SliderThumb.astro — RangeSlider compound part (see ../../README.md).
// A drawn thumb for a theme that hides the native ones. Empty and `aria-hidden`; the script writes
// its `left` as a percentage so it tracks the input it mirrors.
//
// It is OPTIONAL, and leaving it out is a perfectly good choice: the native range inputs come with
// thumbs that work. Reach for this only when the design needs something the vendor pseudo-elements
// cannot draw — and remember that hiding the native input means the theme owns the focus ring too.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"span"> & { bound?: "low" | "high" };
const { bound = "low", class: className, ...rest } = Astro.props;
---
<span aria-hidden="true" data-slot="slider-thumb" data-bound={bound} class={className} {...rest}
></span>
---
// src/components/ui/forms/range-slider/SliderTrack.astro — RangeSlider compound part (see ../../README.md).
// The rail a theme paints, and the filled span between the two thumbs inside it. Both are empty and
// `aria-hidden`: they carry no information a screen reader needs, because the two range inputs
// already announce their own values.
//
// The script writes the fill's `left` and `width` as PERCENTAGES — that is data (where the selected
// span actually is), not styling, and percentages leave every dimension of the rail to the theme.
// Until a theme positions these, they are inert empty elements and the native sliders are the
// control, which is exactly what "unstyled but operable" means here.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"span">;
const { class: className, ...rest } = Astro.props;
---
<span aria-hidden="true" data-slot="slider-track" class={className} {...rest}>
<span data-slot="slider-range"></span>
</span>
import RangeSlider from "./RangeSlider.astro";
import SliderMarks from "./SliderMarks.astro";
import SliderOutput from "./SliderOutput.astro";
import SliderThumb from "./SliderThumb.astro";
import SliderTrack from "./SliderTrack.astro";
export { RangeSlider, SliderMarks, SliderOutput, SliderThumb, SliderTrack };
export default RangeSlider;
// src/components/ui/forms/range-slider/range-pair.ts — the one rule two overlapping range inputs need that
// the platform does not enforce: the thumbs must not cross. Pure, so it is checkable without a DOM
// (see range-pair.test.ts).
//
// Two native <input type="range">s stacked on one track is the whole implementation of RangeSlider,
// and it is worth being clear about why: each thumb is then a real slider with a real keyboard, a
// real value, a real label and a real focus ring. The only thing missing is that neither input knows
// the other exists — so dragging the low thumb past the high one produces a range that reads
// backwards, and every consumer of the value has to defend against it.
export interface RangeBounds {
readonly min: number;
readonly max: number;
/** The smallest gap allowed between the two thumbs. `0` lets them meet. */
readonly minDistance?: number;
}
export interface RangePair {
readonly low: number;
readonly high: number;
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
/**
* Settle a pair after one thumb moved.
*
* The thumb that MOVED keeps its value wherever it can, and the other one is not dragged along: a
* range slider that pushes the far thumb when you overshoot quietly changes a value the user was not
* touching. So the moving thumb stops at the other one instead — the bound it cannot pass.
*
* @param pair - the values both inputs currently report
* @param moved - which thumb the user just moved
* @param bounds - the shared `min`/`max`, and the minimum gap to keep
* @returns the pair to write back, always with `low + minDistance <= high`
* @example clampPair({ low: 80, high: 40 }, "low", { min: 0, max: 100 }) // => { low: 40, high: 40 }
*/
export function clampPair(pair: RangePair, moved: "low" | "high", bounds: RangeBounds): RangePair {
const gap = Math.max(0, bounds.minDistance ?? 0);
const low = clamp(pair.low, bounds.min, bounds.max);
const high = clamp(pair.high, bounds.min, bounds.max);
// A gap wider than the track itself cannot be honoured; pinning both to the ends is the only
// answer that keeps the invariant, and it makes the misconfiguration obvious rather than silent.
if (bounds.max - bounds.min <= gap) return { low: bounds.min, high: bounds.max };
return moved === "low"
? { low: Math.min(low, high - gap), high }
: { low, high: Math.max(high, low + gap) };
}
/**
* Where `value` sits along the track, 0–100, for positioning a thumb or filling a range. Returns 0
* for a zero-width range rather than `NaN`, which would land in a style attribute.
*/
export function percent(value: number, min: number, max: number): number {
if (max <= min) return 0;
return ((clamp(value, min, max) - min) / (max - min)) * 100;
}
What you get
The component source, copied into your project by npx astrocraft-ui add forms/range-slider — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.