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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/range-slider/RangeSlider.astro
---
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>
src/components/ui/forms/range-slider/SliderMarks.astro
---
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>
src/components/ui/forms/range-slider/SliderOutput.astro
---
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
---
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
---
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
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.