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
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.
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
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
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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label. Each thumb is named Minimum or Maximum followed by it. |
value | [number, number] | none | Controlled minimum and maximum. Pair it with onValueChange. |
defaultValue | [number, number] | [min, max] | Uncontrolled starting range. |
onValueChange | (value: [number, number]) => void | none | Called whenever either thumb moves. |
min | number | 0 | Lowest value on the track. |
max | number | 100 | Highest value on the track. |
step | number | 1 | Granularity of both values. |
minGap | number | 0 | Smallest distance kept between the thumbs, so they never cross. |
format | (n: number) => string | String | Turns a value into text for the readout and for aria-valuetext, e.g. $40. |
name | string | none | Submits two hidden inputs, name[0] and name[1]. |
disabled | boolean | false | Dims the part and ignores pointer and keys. |
...rest | ComponentProps<"div"> | none | Every native div attribute, including id, className, style and ref, on the group wrapper. |
Anatomy
| root | .eq-range-slider | A group labelled by the label; carries both fill positions. |
|---|---|---|
| header | .eq-range-slider-header | Holds the label and the readout on one line. |
| label | .eq-range-slider-label | |
| values | .eq-range-slider-values | Both values as text, hidden from assistive tech. |
| track | .eq-range-slider-track | A 24px hit area; the bar is its ::before. |
| fill | .eq-range-slider-fill | The selected span between the thumbs. |
| thumb | .eq-range-slider-thumb | One per end, with data-thumb set to min or max. |
| thumb name | .eq-range-slider-sr | Visually hidden Minimum or Maximum, read before the label. |
Keyboard
| Tab | Moves between the minimum and the maximum thumb. |
|---|---|
| ArrowRightArrowUp | Increases the focused thumb by one step. |
| ArrowLeftArrowDown | Decreases the focused thumb by one step. |
| PageUpPageDown | Changes the focused thumb by a tenth of the range. |
| HomeEnd | Moves 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;
}
}