Inputs

Range slider

Picks a minimum and a maximum on one track, for filters like price, size or time of day.

Built on
role="slider"Pointer capture<input type="hidden">
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.3 kB
Dependencies
React
Price
Minimum
Maximum
Try its options

Loading its options.

Code
"use client";

import { RangeSlider } from "@/components/equip/range-slider";

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

export default function Demo() {
  return (
    <div style={{ width: 200 }}>
      <RangeSlider label="Price" min={0} max={250} step={5} defaultValue={[40, 180]} format={(n) => usd.format(n)} />
    </div>
  );
}
Your versionchange its options in Look, This part

Examples

Minimum gap

The thumbs stop two hours apart, and each thumb reports the narrowed limits it can reach.

Quiet hours
Minimum
Maximum
Code
"use client";

import { RangeSlider } from "@/components/equip/range-slider";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 260 }}>
      <RangeSlider
        label="Quiet hours"
        min={0}
        max={24}
        minGap={2}
        defaultValue={[22, 24]}
        format={(n) => `${String(n % 24).padStart(2, "0")}:00`}
        name="quiet"
      />
    </div>
  );
}

Controlled filter

Image size
Minimum
Maximum

billing-worker, thumbnailer

Code
"use client";

import { useState } from "react";
import { RangeSlider } from "@/components/equip/range-slider";

const sizes = [
  { name: "api-gateway", mb: 38 },
  { name: "billing-worker", mb: 112 },
  { name: "search-indexer", mb: 260 },
  { name: "thumbnailer", mb: 74 },
];

export default function Example() {
  const [range, setRange] = useState<[number, number]>([50, 200]);
  const shown = sizes.filter((s) => s.mb >= range[0] && s.mb <= range[1]);
  return (
    <div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 260 }}>
      <RangeSlider
        label="Image size"
        min={0}
        max={300}
        step={10}
        value={range}
        onValueChange={setRange}
        format={(n) => `${n} MB`}
      />
      <p style={{ margin: 0, fontSize: 13 }}>{shown.map((s) => s.name).join(", ") || "No images in this range"}</p>
    </div>
  );
}

Disabled

Autoscale replicas
Minimum
Maximum
Code
"use client";

import { RangeSlider } from "@/components/equip/range-slider";

export default function Example() {
  return (
    <div style={{ width: "100%", maxWidth: 260 }}>
      <RangeSlider label="Autoscale replicas" min={1} max={20} defaultValue={[2, 8]} format={(n) => `${n} pods`} disabled />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label. Each thumb is named Minimum or Maximum followed by it.
value[number, number]noneControlled minimum and maximum. Pair it with onValueChange.
defaultValue[number, number][min, max]Uncontrolled starting range.
onValueChange(value: [number, number]) => voidnoneCalled whenever either thumb moves.
minnumber0Lowest value on the track.
maxnumber100Highest value on the track.
stepnumber1Granularity of both values.
minGapnumber0Smallest distance kept between the thumbs, so they never cross.
format(n: number) => stringStringTurns a value into text for the readout and for aria-valuetext, e.g. $40.
namestringnoneSubmits two hidden inputs, name[0] and name[1].
disabledbooleanfalseDims the part and ignores pointer and keys.
...restComponentProps<"div">noneEvery native div attribute, including id, className, style and ref, on the group wrapper.

Anatomy

root.eq-range-sliderA group labelled by the label; carries both fill positions.
header.eq-range-slider-headerHolds the label and the readout on one line.
label.eq-range-slider-label
values.eq-range-slider-valuesBoth values as text, hidden from assistive tech.
track.eq-range-slider-trackA 24px hit area; the bar is its ::before.
fill.eq-range-slider-fillThe selected span between the thumbs.
thumb.eq-range-slider-thumbOne per end, with data-thumb set to min or max.
thumb name.eq-range-slider-srVisually hidden Minimum or Maximum, read before the label.

Keyboard

TabMoves between the minimum and the maximum thumb.
ArrowRightArrowUpIncreases the focused thumb by one step.
ArrowLeftArrowDownDecreases the focused thumb by one step.
PageUpPageDownChanges the focused thumb by a tenth of the range.
HomeEndMoves the focused thumb as far as it can go in that direction.

Notes

  • Follows the WAI-ARIA multi-thumb slider pattern: each thumb is its own slider in the tab order.
  • Each thumb reports the limits it can actually reach, so the minimum's maximum is the other thumb less minGap.
  • Pressing the track moves the nearer thumb there; dragging then follows the pointer and the thumb grows a little under it.
  • Use format whenever the bare number is not the meaning, so screen readers hear $40 rather than 40.
  • The readout is hidden from assistive tech because each thumb already announces its own value.
  • Pointer capture keeps a drag going when the pointer leaves the track, with no window listeners.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/range-slider.tsx203 lines
"use client";

import { useId, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from "react";
import "./range-slider.css";

type Range = [number, number];

export type RangeSliderProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
  /** Visible label. Each thumb is named "Minimum" or "Maximum" followed by it. */
  label: ReactNode;
  value?: Range;
  defaultValue?: Range;
  onValueChange?: (value: Range) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Smallest distance kept between the two thumbs. */
  minGap?: number;
  /** Turns a value into text for the readout and for assistive tech, e.g. "$40". */
  format?: (n: number) => string;
  /** Submits two hidden inputs, name[0] and name[1]. */
  name?: string;
  disabled?: boolean;
};

function decimals(n: number) {
  const text = String(n);
  const dot = text.indexOf(".");
  return dot === -1 ? 0 : text.length - dot - 1;
}

export function RangeSlider({
  label,
  value,
  defaultValue,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  minGap = 0,
  format = String,
  name,
  disabled = false,
  className,
  style,
  ...rest
}: RangeSliderProps) {
  const id = useId();
  const trackRef = useRef<HTMLDivElement>(null);
  const thumbRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
  const [inner, setInner] = useState<Range>(() => defaultValue ?? [min, max]);
  const [active, setActive] = useState<0 | 1>(1);
  const [pressed, setPressed] = useState(false);
  const [dragging, setDragging] = useState(false);
  const undecided = useRef(false);
  const current = value ?? inner;
  const [lo, hi] = current;
  const span = max - min;
  const places = decimals(step);

  const snap = (n: number) => Number((min + Math.round((n - min) / step) * step).toFixed(places));
  const bounds = (index: 0 | 1): Range => (index === 0 ? [min, hi - minGap] : [lo + minGap, max]);
  const fraction = (n: number) => (span > 0 ? Math.min(1, Math.max(0, (n - min) / span)) : 0);

  function commit(index: 0 | 1, n: number) {
    const [floor, ceil] = bounds(index);
    const next = Math.min(ceil, Math.max(floor, snap(n)));
    if (next === current[index]) return;
    const range: Range = index === 0 ? [next, hi] : [lo, next];
    if (value === undefined) setInner(range);
    onValueChange?.(range);
  }

  function valueAt(clientX: number) {
    const track = trackRef.current;
    if (!track) return min;
    const box = track.getBoundingClientRect();
    return min + span * Math.min(1, Math.max(0, (clientX - box.left) / box.width));
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    if (disabled || event.button !== 0) return;
    event.preventDefault();
    const n = valueAt(event.clientX);
    // The nearer thumb moves; when they sit together, the side of the click decides.
    const index: 0 | 1 = lo === hi ? (n < lo ? 0 : 1) : Math.abs(n - lo) <= Math.abs(n - hi) ? 0 : 1;
    event.currentTarget.setPointerCapture(event.pointerId);
    // Pressed right on stacked thumbs: the direction of the drag decides which one moves.
    undecided.current = lo === hi && snap(n) === lo;
    setActive(index);
    setPressed(true);
    thumbRefs[index].current?.focus();
    commit(index, n);
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    setDragging(true);
    const n = valueAt(event.clientX);
    let index = active;
    if (undecided.current) {
      if (snap(n) === lo) return;
      undecided.current = false;
      index = n < lo ? 0 : 1;
      setActive(index);
      thumbRefs[index].current?.focus();
    }
    commit(index, n);
  }

  function handlePointerEnd() {
    setPressed(false);
    setDragging(false);
  }

  function handleKeyDown(index: 0 | 1, event: KeyboardEvent<HTMLDivElement>) {
    if (disabled) return;
    const big = Math.max(step, Math.round(span / 10 / step) * step);
    const n = current[index];
    const [floor, ceil] = bounds(index);
    const moves: Record<string, number> = {
      ArrowRight: n + step,
      ArrowUp: n + step,
      ArrowLeft: n - step,
      ArrowDown: n - step,
      PageUp: n + big,
      PageDown: n - big,
      Home: floor,
      End: ceil,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    setActive(index);
    commit(index, moves[event.key]);
  }

  return (
    <div
      role="group"
      aria-labelledby={`${id}-label`}
      {...rest}
      className={["eq-range-slider", className].filter(Boolean).join(" ")}
      style={{ ...style, "--_lo": fraction(lo), "--_hi": fraction(hi) } as CSSProperties}
      data-disabled={disabled || undefined}
    >
      <div className="eq-range-slider-header">
        <span className="eq-range-slider-label" id={`${id}-label`}>
          {label}
        </span>
        {/* Each thumb announces its own value, so the readout stays out of the way. */}
        <span className="eq-range-slider-values" aria-hidden="true">
          {format(lo)} to {format(hi)}
        </span>
      </div>
      <div
        ref={trackRef}
        className="eq-range-slider-track"
        data-dragging={dragging || undefined}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerEnd}
        onPointerCancel={handlePointerEnd}
      >
        <div className="eq-range-slider-fill" />
        {([0, 1] as const).map((index) => {
          const [floor, ceil] = bounds(index);
          return (
            <div
              key={index}
              ref={thumbRefs[index]}
              className="eq-range-slider-thumb"
              role="slider"
              tabIndex={disabled ? -1 : 0}
              aria-labelledby={`${id}-thumb-${index} ${id}-label`}
              aria-valuemin={floor}
              aria-valuemax={ceil}
              aria-valuenow={current[index]}
              aria-valuetext={format(current[index])}
              aria-orientation="horizontal"
              aria-disabled={disabled || undefined}
              data-thumb={index === 0 ? "min" : "max"}
              data-active={active === index || undefined}
              data-pressed={(pressed && active === index) || undefined}
              onKeyDown={(event) => handleKeyDown(index, event)}
              onFocus={() => setActive(index)}
            >
              <span className="eq-range-slider-sr" id={`${id}-thumb-${index}`}>
                {index === 0 ? "Minimum" : "Maximum"}
              </span>
            </div>
          );
        })}
      </div>
      {name && (
        <>
          <input type="hidden" name={`${name}[0]`} value={lo} disabled={disabled} />
          <input type="hidden" name={`${name}[1]`} value={hi} disabled={disabled} />
        </>
      )}
    </div>
  );
}
components/equip/range-slider.css128 lines
@layer equip {
  .eq-range-slider {
    --_thumb: 16px;
    --_track: 6px;
    /* A thumb's centre travels the track minus one thumb, so the ends stay inside it. */
    --_lo-at: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_lo, 0));
    --_hi-at: calc(var(--_thumb) / 2 + (100% - var(--_thumb)) * var(--_hi, 1));

    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  .eq-range-slider-header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--eq-space-2);
  }

  .eq-range-slider-label {
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-range-slider-values {
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  /* The track is 24px tall so it is easy to hit; the bar is drawn inside it. */
  .eq-range-slider-track {
    position: relative;
    height: 24px;
    cursor: pointer;
    touch-action: none;
    user-select: none;
  }

  .eq-range-slider-track::before {
    content: "";
    position: absolute;
    inset-inline: 0;
    top: 50%;
    height: var(--_track);
    translate: 0 -50%;
    border-radius: var(--eq-radius-full);
    background: var(--eq-tint-strong);
  }

  .eq-range-slider-fill {
    position: absolute;
    top: 50%;
    left: var(--_lo-at);
    right: calc(100% - var(--_hi-at));
    height: var(--_track);
    translate: 0 -50%;
    background: var(--eq-solid);
    transition:
      left var(--eq-dur-move) var(--eq-spring),
      right var(--eq-dur-move) var(--eq-spring);
  }

  .eq-range-slider-thumb {
    position: absolute;
    top: 50%;
    left: var(--_lo-at);
    box-sizing: border-box;
    width: var(--_thumb);
    height: var(--_thumb);
    translate: -50% -50%;
    border: 2px solid var(--eq-solid);
    border-radius: var(--eq-radius-full);
    background: var(--eq-raised);
    transition:
      left var(--eq-dur-move) var(--eq-spring),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  /* The thumb itself is 16px; this ring widens what a finger can grab to 28px. */
  .eq-range-slider-thumb::before {
    content: "";
    position: absolute;
    inset: -6px;
    border-radius: var(--eq-radius-full);
  }

  .eq-range-slider-thumb[data-thumb="max"] {
    left: var(--_hi-at);
  }

  .eq-range-slider-thumb[data-active] {
    z-index: 1;
  }

  .eq-range-slider-thumb[data-pressed] {
    scale: 1.15;
  }

  /* While dragging, the thumbs and fill follow the pointer exactly; the spring is for clicks and keys. */
  .eq-range-slider-track[data-dragging] :is(.eq-range-slider-fill, .eq-range-slider-thumb) {
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-range-slider-thumb:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-range-slider-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .eq-range-slider[data-disabled] {
    opacity: 0.45;
  }

  .eq-range-slider[data-disabled] .eq-range-slider-track {
    cursor: not-allowed;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index