Skip to main content
astrocraft-ui/ components · 101

Range Slider

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/range-slider

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/range-slider --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/range-slider --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/range-slider --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/range-slider --theme tailwind --bridge lumos

Live 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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
---
// 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
---
// 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
---
// 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>
src/components/ui/forms/range-slider/index.ts
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
// 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.