Skip to content
Uxelith
Sign in
Freev1.0.0Forms

Slider

A range slider on the native input: pull past either end and the track stretches like rubber, then springs back. Value bubble, labelled marks, end icons and formatted values for screen readers.

pnpm dlx shadcn add …/r/slider.json
Licence
MIT
Packages
3
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/slider.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as slider.tsx. It's yours: change anything.

Usage

example.tsx
import { Slider } from "@/components/slider"

export function Budget() {
  return (
    <Slider
      label="Monthly budget"
      defaultValue={40}
      max={200}
      step={5}
      formatOptions={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
    />
  )
}

Source

One file, 212 lines, nothing hidden. Read it before you install it.

slider.tsx
"use client";

import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface SliderProps
  extends Omit<React.ComponentProps<"input">, "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step" | "size"> {
  /** Visible label. Without one, pass `aria-label`. */
  label?: React.ReactNode;
  min?: number;
  max?: number;
  step?: number;
  /** Controlled value. */
  value?: number;
  /** Initial value when uncontrolled. Defaults to `min`. */
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  /** Where the current value shows: beside the label, in a bubble over the thumb while dragging, or nowhere. */
  valueDisplay?: "label" | "bubble" | "none";
  /** How the value is written, e.g. `{ style: "currency", currency: "USD" }` or `{ style: "unit", unit: "percent" }`. */
  formatOptions?: Intl.NumberFormatOptions;
  /** Locale for formatting. Defaults to the visitor's. */
  locale?: string;
  /** Classes for the native range input (the track). `className` styles the wrapper. */
  inputClassName?: string;
  /** Labelled stops under the track, e.g. `[{ value: 0, label: "Off" }, { value: 50 }]`. */
  marks?: { value: number; label?: React.ReactNode }[];
  /** Icons at either end of the track, e.g. quiet and loud speakers. */
  startIcon?: React.ReactNode;
  endIcon?: React.ReactNode;
}

const THUMB = 18; // px — keep in sync with the thumb size classes below
const MAX_STRETCH = 14; // px the track can be pulled past either end

// Motion presets (ADR-010).
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;

/** Rubber-band resistance: the further you pull, the less it gives. */
const rubber = (distance: number) => MAX_STRETCH * (1 - Math.exp(-distance / 120));

/**
 * A range slider built on the native `<input type="range">`, so keyboard,
 * screen readers, forms and touch all work as the platform intends.
 *
 * - ←/→ step, PageUp/PageDown jump, Home/End go to the ends.
 * - Screen readers hear the formatted value (`aria-valuetext`), e.g. "$40".
 * - The filled part of the track follows the value; the thumb grows while
 *   dragged, and pulling past either end stretches the track like rubber
 *   before it springs back (not under reduced motion).
 * - Optional `marks` label stops under the track; `startIcon`/`endIcon` sit
 *   at its ends. Colours: `--slider-fill` (default `--primary`).
 */
export function Slider({
  label,
  min = 0,
  max = 100,
  step = 1,
  value: valueProp,
  defaultValue,
  onValueChange,
  valueDisplay = "label",
  formatOptions,
  locale,
  id: idProp,
  disabled,
  className,
  inputClassName,
  marks,
  startIcon,
  endIcon,
  style,
  onPointerDown,
  ...props
}: SliderProps) {
  const generatedId = React.useId();
  const id = idProp ?? generatedId;
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? min);
  const value = Math.min(max, Math.max(min, valueProp ?? uncontrolled));
  const pct = max === min ? 0 : ((value - min) / (max - min)) * 100;
  const formatted = React.useMemo(() => new Intl.NumberFormat(locale, formatOptions), [locale, formatOptions]).format(value);
  // The thumb's centre travels THUMB px less than the full width.
  const at = (v: number) => {
    const p = max === min ? 0 : ((v - min) / (max - min)) * 100;
    return `calc(${p}% + ${THUMB / 2 - (p / 100) * THUMB}px)`;
  };

  const reduceMotion = useReducedMotion();
  const trackRef = React.useRef<HTMLDivElement>(null);
  // Pixels pulled past the end: positive on the right, negative on the left.
  const stretch = useMotionValue(0);
  const scaleX = useTransform(stretch, (px) => 1 + Math.abs(px) / (trackRef.current?.offsetWidth || 200));
  const scaleY = useTransform(stretch, (px) => 1 - Math.abs(px) / 200);
  const origin = useTransform(stretch, (px) => (px < 0 ? "right" : "left"));

  const startStretch = (event: React.PointerEvent<HTMLInputElement>) => {
    onPointerDown?.(event);
    const track = trackRef.current;
    if (reduceMotion || disabled || !track) return;
    const move = (e: PointerEvent) => {
      const rect = track.getBoundingClientRect();
      if (e.clientX > rect.right) stretch.set(rubber(e.clientX - rect.right));
      else if (e.clientX < rect.left) stretch.set(-rubber(rect.left - e.clientX));
      else stretch.set(0);
    };
    const end = () => {
      void animate(stretch, 0, SPRING_SMOOTH);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", end);
      window.removeEventListener("pointercancel", end);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", end);
    window.addEventListener("pointercancel", end);
  };

  return (
    <div className={cn("grid w-full gap-3", className)} style={style}>
      {(label || valueDisplay === "label") && (
        <div className="flex items-baseline justify-between gap-4 text-sm">
          {label && (
            <label htmlFor={id} className={cn("font-medium", disabled && "opacity-50")}>
              {label}
            </label>
          )}
          {valueDisplay === "label" && (
            // The input already announces its value; this is the visible copy.
            <output htmlFor={id} aria-hidden="true" className="ml-auto font-medium tabular-nums text-muted-foreground">
              {formatted}
            </output>
          )}
        </div>
      )}
      <div className="flex items-center gap-3 text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
        {startIcon && <span aria-hidden="true">{startIcon}</span>}
        <motion.div
          ref={trackRef}
          style={{ scaleX, scaleY, transformOrigin: origin }}
          className="relative flex h-5 flex-1 items-center text-foreground"
        >
          <input
            id={id}
            type="range"
            min={min}
            max={max}
            step={step}
            value={value}
            disabled={disabled}
            aria-valuetext={formatted}
            onChange={(event) => {
              const next = Number(event.target.value);
              if (valueProp === undefined) setUncontrolled(next);
              onValueChange?.(next);
            }}
            onPointerDown={startStretch}
            className={cn(
              "peer h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
              "bg-[linear-gradient(to_right,var(--slider-fill,var(--primary))_var(--pct),var(--muted)_var(--pct))]",
              "[&::-webkit-slider-thumb]:size-[18px] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full",
              "[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--slider-fill,var(--primary))] [&::-webkit-slider-thumb]:bg-background",
              "[&::-webkit-slider-thumb]:shadow-[0_1px_3px_rgb(0_0_0/0.25)] [&::-webkit-slider-thumb]:transition-[scale,box-shadow] [&::-webkit-slider-thumb]:duration-200",
              "active:[&::-webkit-slider-thumb]:scale-125 focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_4px_color-mix(in_oklab,var(--ring)_45%,transparent)]",
              "[&::-moz-range-thumb]:size-[18px] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2",
              "[&::-moz-range-thumb]:border-[var(--slider-fill,var(--primary))] [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-[0_1px_3px_rgb(0_0_0/0.25)]",
              "[&::-moz-range-thumb]:transition-[scale,box-shadow] [&::-moz-range-thumb]:duration-200 active:[&::-moz-range-thumb]:scale-125",
              "focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_4px_color-mix(in_oklab,var(--ring)_45%,transparent)]",
              "motion-reduce:[&::-webkit-slider-thumb]:transition-none motion-reduce:[&::-moz-range-thumb]:transition-none",
              inputClassName,
            )}
            style={{ "--pct": `${pct}%` } as React.CSSProperties}
            {...props}
          />
          {valueDisplay === "bubble" && (
            <span
              aria-hidden="true"
              className={cn(
                "pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 translate-y-1 scale-90 rounded-md bg-foreground px-2 py-0.5 text-xs font-medium tabular-nums text-background opacity-0 shadow-md",
                "transition-[opacity,translate,scale] duration-200 motion-reduce:transition-none",
                "peer-hover:translate-y-0 peer-hover:scale-100 peer-hover:opacity-100 peer-focus-visible:translate-y-0 peer-focus-visible:scale-100 peer-focus-visible:opacity-100 peer-active:translate-y-0 peer-active:scale-100 peer-active:opacity-100",
                "peer-disabled:hidden",
              )}
              style={{ left: at(value) }}
            >
              {formatted}
            </span>
          )}
        </motion.div>
        {endIcon && <span aria-hidden="true">{endIcon}</span>}
      </div>
      {marks && marks.length > 0 && (
        // Visual only: the input already exposes every value to assistive tech.
        <div aria-hidden="true" className={cn("relative -mt-1 h-9 text-xs text-muted-foreground", startIcon && "ml-7", endIcon && "mr-7")}>
          {marks.map((mark) => (
            <span
              key={mark.value}
              className={cn(
                "absolute top-0 flex -translate-x-1/2 flex-col items-center gap-1 tabular-nums transition-colors duration-150",
                mark.value <= value && "text-foreground",
              )}
              style={{ left: at(mark.value) }}
            >
              <span className={cn("h-1.5 w-px", mark.value <= value ? "bg-foreground/40" : "bg-border")} />
              {mark.label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
labelReactNode—Visible label. Without it, pass aria-label.
min / max / stepnumber0 / 100 / 1Range and step.
value / defaultValuenumber— / minControlled or initial value.
onValueChange(value: number) => void—Called as the value changes.
valueDisplay"label" | "bubble" | "none""label"Show the value beside the label, over the thumb while dragging, or not at all.
formatOptionsIntl.NumberFormatOptions—How the value is written, e.g. currency or percent.
localestringvisitor'sLocale for formatting.
namestring—Submit with forms, like any range input.
marks{ value: number; label?: ReactNode }[]—Labelled stops under the track.
startIcon / endIconReactNode—Icons at either end of the track.
--slider-fillCSS variable--primaryColour of the filled track and thumb ring.

Accessibility

  • A native range input: arrow keys, PageUp/PageDown, Home/End, touch and forms work as the platform intends.
  • Screen readers hear the formatted value (aria-valuetext), e.g. "$40".
  • The visible label is a real <label>.
  • Marks and end icons are visual only; the input already exposes every value.
  • No stretch, and instant thumb growth and bubble, under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Forms

All 10 in Forms
  • FormsFree

    Animated Input

    A text field whose label floats out of the way, with examples that take turns while it's empty, an error that shakes once, a tick for a valid value and sent text that drifts away.

  • FormsPro

    Date Picker

    Single dates and ranges in a keyboard-first calendar, with one-tap presets ("Last 7 days"), months that slide the way you go, min/max, unavailable days and ISO strings for forms.

  • FormsPro

    File Dropzone

    Drop, paste or browse for files: the zone lights up as a file crosses the page and warns before a wrong type lands; files show thumbnails and upload with live, cancellable progress.