Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/slider.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
slider.tsx. It's yours: change anything.
Usage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | Visible label. Without it, pass aria-label. |
| min / max / step | number | 0 / 100 / 1 | Range and step. |
| value / defaultValue | number | — / min | Controlled 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. |
| formatOptions | Intl.NumberFormatOptions | — | How the value is written, e.g. currency or percent. |
| locale | string | visitor's | Locale for formatting. |
| name | string | — | Submit with forms, like any range input. |
| marks | { value: number; label?: ReactNode }[] | — | Labelled stops under the track. |
| startIcon / endIcon | ReactNode | — | Icons at either end of the track. |
| --slider-fill | CSS variable | --primary | Colour 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
- v1.0.0Latest
First release.