Slider
A value or a range on a rail, dragged directly, with the value over the thumb while you move it.
The idea
Dragging is direct — no spring, no easing — because a thumb that lags the pointer feels pulled through mud. The pointer is captured on press, so a drag that wanders off the rail keeps working, and a press anywhere on the rail jumps the nearest thumb there and carries on as a drag. Each thumb is a slider with the keyboard a native range has, and an aria-valuetext written with your format, so a price is read as a price. Two thumbs cannot cross; each one's bounds are the other.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Slider } from "mihirmodi-ui";
// One thumb: a number.
<Slider label="Volume" value={volume} onChange={setVolume} format={(n) => `${n}%`} tooltip />
// Two thumbs: a [low, high] pair. onCommit fires once, when the drag
// ends — the moment to fetch, not on every pixel of the way there.
<Slider
label="Price per night"
value={price}
onChange={setPrice}
onCommit={search}
max={120}
step={5}
format={(n) => `£${n}`}
marks={[0, 60, 120].map((n) => ({ value: n, label: `£${n}` }))}
/>Props
| Prop | Type | Notes |
|---|---|---|
| value / onChangenumber | [number, number] / (value) => void | number | [number, number] / (value) => void | A number for one thumb, a pair for a range. |
| onCommit(value) => void | (value) => void | Once, when a drag ends or a key is released — the moment to save or search. |
| min / max / stepnumber | number= 0 / 100 / 1 | Page Up and Down move a tenth of the rail. |
| format(n) => string | (n) => string | Writes the value for the tooltip and for aria-valuetext. |
| tooltipboolean | boolean= false | Show the value over a thumb while it is dragged or keyboard-focused. |
| marks(number | { value, label? })[] | (number | { value, label? })[] | Ticks under the rail. |
| label / aria-labelledby / thumbLabelsstring / string / [string, string] | string / string / [string, string] | The accessible name; a range suffixes each thumb's. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
export type SliderMark = number | { value: number; label?: ReactNode };
export type SliderProps<V extends number | [number, number] = number> = {
/** Controlled. A number for one thumb, `[low, high]` for a range. */
value?: V;
defaultValue?: V;
/** Every change, while dragging and per key press. */
onChange?: (value: V) => void;
/** Once, when a drag ends or a key is released — the moment to save. */
onCommit?: (value: V) => void;
min?: number;
max?: number;
step?: number;
/** How a value is written, for the tooltip and for `aria-valuetext`. */
format?: (value: number) => string;
/** Show the value over the thumb while it is dragged or focused from the keyboard. */
tooltip?: boolean;
/** Ticks under the rail, with an optional label each. */
marks?: SliderMark[];
/** The accessible name. For a range it is suffixed with `thumbLabels`. */
label?: string;
"aria-labelledby"?: string;
/** Names for the two thumbs of a range. */
thumbLabels?: [string, string];
disabled?: boolean;
className?: string;
};
const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
/**
* A value on a rail — one thumb, or two for a range.
*
* The rail is a grey well and the chosen stretch of it fills with ink, the
* same fill that means chosen everywhere else here. The thumb is the raised
* highlight surface, like a switch's, and grows a touch while held so the
* hand can feel it has hold of something. Dragging is direct: no spring, no
* easing, because a thumb that lags the pointer feels like it is being pulled
* through mud. The pointer is captured on press, so a drag that wanders off
* the rail keeps working, and a press anywhere on the rail jumps the nearest
* thumb there and carries on as a drag.
*
* Each thumb is a `role="slider"` with the keyboard a native range has —
* arrows step, Page Up and Down take a tenth, Home and End go to the ends —
* and an `aria-valuetext` written with your `format`, so "£40" is read out
* rather than "40". Two thumbs cannot cross; each one's bounds are the other.
*/
export function Slider<V extends number | [number, number] = number>({
value,
defaultValue,
onChange,
onCommit,
min = 0,
max = 100,
step = 1,
format = (n) => String(n),
tooltip = false,
marks,
label,
"aria-labelledby": labelledBy,
thumbLabels = ["Minimum", "Maximum"],
disabled,
className = "",
}: SliderProps<V>) {
const range = Array.isArray(value ?? defaultValue);
const toArr = (v: V | undefined): number[] => (v === undefined ? (range ? [min, max] : [min]) : Array.isArray(v) ? [...v] : [v as number]);
const [inner, setInner] = useState<number[]>(() => toArr(defaultValue));
const vals = value !== undefined ? toArr(value) : inner;
const latest = useRef(vals);
latest.current = vals;
const rail = useRef<HTMLDivElement>(null);
const thumbs = useRef<(HTMLSpanElement | null)[]>([]);
const [drag, setDrag] = useState<number | null>(null);
const [keyFocus, setKeyFocus] = useState<number | null>(null);
const out = (next: number[]) => (range ? [next[0], next[1]] : next[0]) as V;
const pct = (n: number) => (max === min ? 0 : ((n - min) / (max - min)) * 100);
const snap = (n: number) => {
const s = Math.round((n - min) / step) * step + min;
return Number(Math.min(max, Math.max(min, s)).toFixed(decimals(step)));
};
// Each thumb's bounds: the rail's ends, or the other thumb.
const lo = (i: number) => (range && i === 1 ? vals[0] : min);
const hi = (i: number) => (range && i === 0 ? vals[1] : max);
const set = (i: number, n: number) => {
const cur = latest.current;
const clamped = Math.min(range && i === 0 ? cur[1] : max, Math.max(range && i === 1 ? cur[0] : min, snap(n)));
if (clamped === cur[i]) return;
const next = [...cur];
next[i] = clamped;
latest.current = next;
if (value === undefined) setInner(next);
onChange?.(out(next));
};
const fromPointer = (clientX: number) => {
const r = rail.current!.getBoundingClientRect();
const t = r.width ? Math.min(1, Math.max(0, (clientX - r.left) / r.width)) : 0;
return min + t * (max - min);
};
const onDown = (e: PointerEvent<HTMLDivElement>) => {
if (disabled || e.button !== 0) return;
e.preventDefault();
const n = fromPointer(e.clientX);
let i = 0;
if (range) {
const [a, b] = vals;
// Equal thumbs: take the one on the side the press is on.
i = a === b ? (n > a ? 1 : 0) : Math.abs(n - a) <= Math.abs(n - b) ? 0 : 1;
}
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(i);
thumbs.current[i]?.focus({ preventScroll: true });
set(i, n);
};
const onMove = (e: PointerEvent<HTMLDivElement>) => {
if (drag !== null) set(drag, fromPointer(e.clientX));
};
const onUp = (e: PointerEvent<HTMLDivElement>) => {
if (drag === null) return;
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
setDrag(null);
onCommit?.(out(latest.current));
};
const onKey = (e: KeyboardEvent, i: number) => {
if (disabled) return;
const big = Math.max(step, snap(min + (max - min) / 10) - min);
const delta: Record<string, number> = {
ArrowRight: step,
ArrowUp: step,
ArrowLeft: -step,
ArrowDown: -step,
PageUp: big,
PageDown: -big,
};
if (e.key in delta) set(i, latest.current[i] + delta[e.key]);
else if (e.key === "Home") set(i, lo(i));
else if (e.key === "End") set(i, hi(i));
else return;
e.preventDefault();
};
const a = range ? pct(vals[0]) : 0;
const b = pct(range ? vals[1] : vals[0]);
const marked = (marks ?? []).map((mk) => (typeof mk === "number" ? { value: mk, label: undefined } : mk));
return (
<div className={`relative w-full select-none ${disabled ? "cursor-not-allowed opacity-50" : ""} ${className}`}>
<div
ref={rail}
onPointerDown={onDown}
// The thumb is focused by hand on press; the rail's own mousedown would blur it again.
onMouseDown={(e) => e.preventDefault()}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className={`relative flex h-5 touch-none items-center ${disabled ? "pointer-events-none" : "cursor-pointer"}`}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-gray-alpha-300">
<div className="absolute inset-y-0 rounded-full bg-ink" style={{ left: `${a}%`, right: `${100 - b}%` }} />
</div>
{vals.map((v, i) => {
const shown = tooltip && (drag === i || keyFocus === i);
const name = range ? (label ? `${label}, ${thumbLabels[i].toLowerCase()}` : thumbLabels[i]) : label;
return (
<span key={i} className="absolute top-1/2" style={{ left: `${pct(v)}%` }}>
<span
ref={(el) => {
thumbs.current[i] = el;
}}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={labelledBy ? undefined : name}
aria-labelledby={labelledBy}
aria-valuemin={lo(i)}
aria-valuemax={hi(i)}
aria-valuenow={v}
aria-valuetext={format(v)}
aria-orientation="horizontal"
aria-disabled={disabled || undefined}
onKeyDown={(e) => onKey(e, i)}
onKeyUp={(e) => {
if (["ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(e.key)) onCommit?.(out(latest.current));
}}
onFocus={(e) => setKeyFocus(e.currentTarget.matches(":focus-visible") ? i : null)}
onBlur={() => setKeyFocus(null)}
className={`absolute left-0 top-0 block h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-highlight shadow-[0_1px_3px_rgba(0,0,0,0.18),0_0_0_0.5px_var(--color-gray-alpha-500)] transition-transform duration-150 ease-geist before:absolute before:-inset-2 before:content-[''] motion-reduce:transition-none ${
drag === i ? "scale-[1.2]" : ""
}`}
/>
{tooltip && (
<span
aria-hidden
className={`pointer-events-none absolute bottom-3.5 left-0 inline-flex h-[22px] -translate-x-1/2 items-center whitespace-nowrap rounded-md bg-ink px-2 text-[11px] font-medium leading-none tabular-nums text-background-100 shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)] transition-opacity duration-150 ease-out motion-reduce:transition-none ${
shown ? "opacity-100" : "opacity-0"
}`}
>
{format(v)}
</span>
)}
</span>
);
})}
</div>
{marked.length > 0 && (
<div aria-hidden className={`relative ${marked.some((mk) => mk.label !== undefined) ? "h-6" : "h-2"}`}>
{marked.map((mk) => (
<span key={mk.value} className="absolute top-0 flex -translate-x-1/2 flex-col items-center gap-1" style={{ left: `${pct(mk.value)}%` }}>
<span className="h-1 w-px bg-gray-alpha-600" />
{mk.label !== undefined && <span className="text-[11px] leading-none tabular-nums text-faint">{mk.label}</span>}
</span>
))}
</div>
)}
</div>
);
}