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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
min?: number;
max?: number;
step?: number;
low?: number;
high?: number;
name?: string;
label?: string;
lowLabel?: string;
highLabel?: string;
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"]');
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,
);
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));
}
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
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>
---
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>
---
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>
---
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;
export interface RangeBounds {
readonly min: number;
readonly max: number;
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));
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);
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) };
}
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.