"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type SliderMark = { value: number; label?: React.ReactNode };
export interface SliderProps {
/** One value = single thumb, two values = range. */
value?: number[];
defaultValue?: number[];
onChange?: (value: number[]) => void;
/** Fires when the user releases a thumb or commits via keyboard. */
onCommit?: (value: number[]) => void;
min?: number;
max?: number;
step?: number;
/** Minimum distance between the two thumbs, in steps. */
minStepsBetween?: number;
/** `true` = marks at every step, or pass explicit marks. */
marks?: boolean | SliderMark[];
/** Snap to marks only (ignores `step`). */
snapToMarks?: boolean;
orientation?: "horizontal" | "vertical";
/** "auto" shows on hover/drag/focus. */
tooltip?: "auto" | "always" | "never";
formatValue?: (v: number) => string;
/** Accessible names for each thumb. */
thumbLabels?: string[];
label?: string;
disabled?: boolean;
name?: string;
className?: string;
/** Length of a vertical slider (CSS size). */
height?: number | string;
}
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
export function Slider({
value: valueProp,
defaultValue,
onChange,
onCommit,
min = 0,
max = 100,
step = 1,
minStepsBetween = 0,
marks,
snapToMarks = false,
orientation = "horizontal",
tooltip = "auto",
formatValue = (v) => String(v),
thumbLabels,
label,
disabled = false,
name,
className,
height = 200,
}: SliderProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const [inner, setInner] = React.useState<number[]>(defaultValue ?? [min]);
const values = valueProp ?? inner;
const trackRef = React.useRef<HTMLDivElement>(null);
const thumbRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const [dragging, setDragging] = React.useState<number | null>(null);
const [hover, setHover] = React.useState<number | null>(null);
const [focus, setFocus] = React.useState<number | null>(null);
const vertical = orientation === "vertical";
const range = values.length > 1;
const gap = minStepsBetween * step;
const markList: SliderMark[] = React.useMemo(() => {
if (!marks) return [];
if (marks === true) {
const out: SliderMark[] = [];
for (let v = min; v <= max + 1e-9; v += step) out.push({ value: +v.toFixed(decimals(step)) });
return out;
}
return marks;
}, [marks, min, max, step]);
const pct = (v: number) => ((v - min) / (max - min)) * 100;
const quantize = (raw: number) => {
if (snapToMarks && markList.length) {
return markList.reduce((best, m) => (Math.abs(m.value - raw) < Math.abs(best - raw) ? m.value : best), markList[0].value);
}
const q = Math.round((raw - min) / step) * step + min;
return +clamp(q, min, max).toFixed(decimals(step));
};
const emit = (next: number[]) => {
if (valueProp === undefined) setInner(next);
onChange?.(next);
};
const setThumb = (i: number, raw: number, commit = false) => {
let v = quantize(raw);
if (range) {
if (i === 0) v = Math.min(v, values[1] - gap);
else v = Math.max(v, values[0] + gap);
}
const next = values.slice();
next[i] = clamp(v, min, max);
if (next[i] !== values[i]) emit(next);
if (commit) onCommit?.(next);
return next;
};
const valueFromPointer = (clientX: number, clientY: number) => {
const r = trackRef.current?.getBoundingClientRect();
if (!r) return min;
const ratio = vertical ? 1 - (clientY - r.top) / r.height : (clientX - r.left) / r.width;
return min + clamp(ratio, 0, 1) * (max - min);
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || e.button !== 0) return;
e.preventDefault();
const raw = valueFromPointer(e.clientX, e.clientY);
// nearest thumb (ties → the one that can move in that direction)
let i = 0;
if (range) {
const d0 = Math.abs(values[0] - raw);
const d1 = Math.abs(values[1] - raw);
i = d1 < d0 || (d0 === d1 && raw > values[1]) ? 1 : 0;
}
trackRef.current?.setPointerCapture(e.pointerId);
setDragging(i);
setThumb(i, raw);
thumbRefs.current[i]?.focus({ preventScroll: true });
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (dragging === null) return;
setThumb(dragging, valueFromPointer(e.clientX, e.clientY));
};
const endDrag = () => {
if (dragging === null) return;
onCommit?.(values);
setDragging(null);
};
const onKeyDown = (i: number) => (e: React.KeyboardEvent) => {
if (disabled) return;
const big = Math.max(step, (max - min) / 10);
const v = values[i];
const idx = markList.findIndex((m) => m.value === v);
const map: Record<string, number> = snapToMarks
? {
ArrowRight: markList[Math.min(markList.length - 1, idx + 1)]?.value ?? v,
ArrowUp: markList[Math.min(markList.length - 1, idx + 1)]?.value ?? v,
ArrowLeft: markList[Math.max(0, idx - 1)]?.value ?? v,
ArrowDown: markList[Math.max(0, idx - 1)]?.value ?? v,
Home: min,
End: max,
}
: {
ArrowRight: v + step * (e.shiftKey ? 10 : 1),
ArrowUp: v + step * (e.shiftKey ? 10 : 1),
ArrowLeft: v - step * (e.shiftKey ? 10 : 1),
ArrowDown: v - step * (e.shiftKey ? 10 : 1),
PageUp: v + big,
PageDown: v - big,
Home: min,
End: max,
};
if (map[e.key] === undefined) return;
e.preventDefault();
setThumb(i, map[e.key], true);
};
const lo = range ? values[0] : min;
const hi = range ? values[1] : values[0];
const animateMove = dragging === null && !reduce;
const posStyle = (p: number): React.CSSProperties => (vertical ? { bottom: `${p}%` } : { left: `${p}%` });
return (
<div className={cn(vertical ? "inline-flex flex-col items-center" : "w-full", className)}>
{label && (
<div id={`${uid}-label`} className={cn("mb-3 text-sm font-medium", vertical && "text-center")}>
{label}
</div>
)}
<div
className={cn("relative touch-none select-none", vertical ? "w-10" : "h-10 w-full", disabled && "pointer-events-none opacity-50")}
style={vertical ? { height } : undefined}
>
{/* hit area */}
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn("absolute cursor-pointer", vertical ? "inset-y-0 left-1/2 w-10 -translate-x-1/2" : "inset-x-0 top-1/2 h-10 -translate-y-1/2")}
>
{/* rail */}
<div className={cn("absolute rounded-full bg-muted", vertical ? "inset-y-0 left-1/2 w-1.5 -translate-x-1/2" : "inset-x-0 top-1/2 h-1.5 -translate-y-1/2")}>
<div
className={cn(
"absolute rounded-full bg-gradient-to-r from-primary/80 to-primary",
vertical ? "inset-x-0 bg-gradient-to-t" : "inset-y-0",
animateMove && "transition-[left,right,top,bottom] duration-200 ease-out",
)}
style={vertical ? { bottom: `${pct(lo)}%`, top: `${100 - pct(hi)}%` } : { left: `${pct(lo)}%`, right: `${100 - pct(hi)}%` }}
/>
</div>
{/* marks */}
{markList.map((m) => {
const inside = m.value >= lo && m.value <= hi;
return (
<span
key={m.value}
aria-hidden
className={cn(
"absolute size-1.5 rounded-full transition-colors",
vertical ? "left-1/2 -translate-x-1/2 translate-y-1/2" : "top-1/2 -translate-x-1/2 -translate-y-1/2",
inside ? "bg-primary-foreground/80" : "bg-foreground/25",
)}
style={posStyle(pct(m.value))}
/>
);
})}
</div>
{/* thumbs */}
{values.map((v, i) => {
const show = tooltip === "always" || (tooltip === "auto" && (dragging === i || hover === i || focus === i));
const thumbLabel = thumbLabels?.[i] ?? (range ? (i === 0 ? "Minimum" : "Maximum") : label ?? "Value");
return (
<div
key={i}
ref={(el) => {
thumbRefs.current[i] = el;
}}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={thumbLabel}
aria-labelledby={label && !thumbLabels && !range ? `${uid}-label` : undefined}
aria-valuemin={range && i === 1 ? values[0] + gap : min}
aria-valuemax={range && i === 0 ? values[1] - gap : max}
aria-valuenow={v}
aria-valuetext={formatValue(v)}
aria-orientation={orientation}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown(i)}
onFocus={() => setFocus(i)}
onBlur={() => setFocus(null)}
onPointerEnter={() => setHover(i)}
onPointerLeave={() => setHover(null)}
onPointerDown={(e) => {
// let the track handle dragging, but keep this thumb when thumbs overlap
if (disabled || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
trackRef.current?.setPointerCapture(e.pointerId);
setDragging(i);
thumbRefs.current[i]?.focus({ preventScroll: true });
}}
className={cn(
"group absolute z-10 outline-none",
vertical ? "left-1/2 -translate-x-1/2 translate-y-1/2" : "top-1/2 -translate-x-1/2 -translate-y-1/2",
animateMove && (vertical ? "transition-[bottom] duration-200 ease-out" : "transition-[left] duration-200 ease-out"),
focus === i && "z-20",
)}
style={posStyle(pct(v))}
>
<motion.span
animate={{ scale: dragging === i ? 1.2 : 1 }}
whileHover={reduce ? undefined : { scale: dragging === i ? 1.2 : 1.1 }}
transition={{ type: "spring", stiffness: 600, damping: 26 }}
className={cn(
"block size-5 cursor-grab rounded-full border-2 border-primary bg-background shadow-md shadow-black/15 transition-shadow",
"group-focus-visible:ring-4 group-focus-visible:ring-primary/25",
dragging === i && "cursor-grabbing ring-8 ring-primary/15",
)}
/>
<AnimatePresence>
{show && (
<motion.span
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, ...(vertical ? { x: -4 } : { y: 4 }) }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}
transition={{ type: "spring", stiffness: 600, damping: 28 }}
className={cn(
"pointer-events-none absolute whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-xs font-semibold tabular-nums text-background shadow-lg",
vertical ? "left-[calc(100%+10px)] top-1/2 origin-left -translate-y-1/2" : "bottom-[calc(100%+10px)] left-1/2 origin-bottom -translate-x-1/2",
)}
>
{formatValue(v)}
<span
aria-hidden
className={cn("absolute size-2 rotate-45 bg-foreground", vertical ? "-left-1 top-1/2 -translate-y-1/2" : "-bottom-1 left-1/2 -translate-x-1/2")}
/>
</motion.span>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* mark labels */}
{markList.some((m) => m.label !== undefined) && !vertical && (
<div className="relative mt-1 h-5 w-full text-[11px] text-muted-foreground">
{markList.map((m) =>
m.label === undefined ? null : (
<button
key={m.value}
type="button"
tabIndex={-1}
onClick={() => {
if (disabled) return;
const i = range ? (Math.abs(values[0] - m.value) <= Math.abs(values[1] - m.value) ? 0 : 1) : 0;
setThumb(i, m.value, true);
}}
className={cn(
"absolute -translate-x-1/2 whitespace-nowrap tabular-nums transition-colors hover:text-foreground",
m.value >= lo && m.value <= hi && "text-foreground",
pct(m.value) === 0 && "translate-x-0",
pct(m.value) === 100 && "-translate-x-full",
)}
style={{ left: `${pct(m.value)}%` }}
>
{m.label}
</button>
),
)}
</div>
)}
{name && values.map((v, i) => <input key={i} type="hidden" name={range ? `${name}[${i}]` : name} value={v} />)}
</div>
);
}