← All components

Slider

A value or a range on a rail, dragged directly, with the value over the thumb while you move it.

Volume64%
Price per night£30 – £90

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Slider — usage17 lines
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

PropNotes
value / onChangenumber | [number, number] / (value) => voidA number for one thumb, a pair for a range.
onCommit(value) => voidOnce, when a drag ends or a key is released — the moment to save or search.
min / max / stepnumberPage Up and Down move a tenth of the rail.
format(n) => stringWrites the value for the tooltip and for aria-valuetext.
tooltipbooleanShow the value over a thumb while it is dragged or keyboard-focused.
marks(number | { value, label? })[]Ticks under the rail.
label / aria-labelledby / thumbLabelsstring / 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.

packages/ui/src/components/forms/Slider.tsx225 lines
"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>
  );
}