Opacity
Columns
Duration
Noise floor
Slider
My attempt at recreating the slider from dialkit.dev.
- Uses
- motion
Code
// A div with role="slider": a native range can't draw the label, typed value and stretching fill.
/* oxlint-disable jsx-a11y/prefer-tag-over-role */
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { cn } from "@manikrana/ui/lib/utils";
export type SliderProps = Omit<ComponentProps<"div">, "children" | "defaultValue" | "onChange"> & {
defaultValue?: number[];
disabled?: boolean;
editable?: boolean;
formatValue?: (value: number) => ReactNode;
label?: ReactNode;
max?: number;
min?: number;
name?: string;
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
required?: boolean;
showValue?: boolean;
step?: number;
unit?: string;
value?: number[];
};
const CLICK_THRESHOLD = 3;
const DEAD_ZONE = 32;
const MAX_CURSOR_RANGE = 200;
const MAX_STRETCH = 8;
const HANDLE_BUFFER = 8;
// Must match the label's left-2.5 and the value's right-2.5.
const LABEL_LEFT = 10;
const VALUE_RIGHT = 10;
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
function useMeasuredWidth<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
useIsomorphicLayoutEffect(() => {
const node = ref.current;
if (!node) return undefined;
const updateWidth = () => setWidth(node.offsetWidth);
updateWidth();
if (typeof ResizeObserver === "undefined") return undefined;
const observer = new ResizeObserver(updateWidth);
observer.observe(node);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
function decimalsForStep(step: number) {
const stepText = step.toString();
if (stepText.includes("e-")) {
return Number(stepText.split("e-")[1] ?? 0);
}
const decimalIndex = stepText.indexOf(".");
return decimalIndex === -1 ? 0 : stepText.length - decimalIndex - 1;
}
function clamp(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}
function getSafeStep(step: number) {
return Number.isFinite(step) && step > 0 ? step : 1;
}
function valueToPercent(value: number, min: number, max: number) {
const range = max - min;
if (range <= 0) return 0;
return ((value - min) / range) * 100;
}
function roundValue(value: number, min: number, step: number) {
const rounded = min + Math.round((value - min) / step) * step;
return Number.parseFloat(rounded.toFixed(decimalsForStep(step)));
}
function normalizeValue(value: number, min: number, max: number, step: number) {
return clamp(roundValue(value, min, step), min, max);
}
function snapToDecile(rawValue: number, min: number, max: number) {
const range = max - min;
if (range <= 0) return min;
const normalized = (rawValue - min) / range;
const nearest = Math.round(normalized * 10) / 10;
if (Math.abs(normalized - nearest) <= 0.03125) {
return min + nearest * range;
}
return rawValue;
}
function firstValue(value: number[] | undefined, fallback: number) {
const next = value?.[0];
return typeof next === "number" && Number.isFinite(next) ? next : fallback;
}
export function Slider({
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
className,
defaultValue,
disabled = false,
editable = true,
formatValue,
label,
max = 100,
min = 0,
name,
onKeyDown,
onValueChange,
onValueCommit,
ref,
required,
showValue = true,
step = 1,
unit = "",
value,
...props
}: SliderProps) {
const safeStep = getSafeStep(step);
const safeMin = Math.min(min, max);
const safeMax = Math.max(min, max);
const isControlled = value !== undefined;
const [wrapperRef, wrapperWidth] = useMeasuredWidth<HTMLDivElement>();
const [labelRef, labelWidth] = useMeasuredWidth<HTMLSpanElement>();
const [valueRef, valueWidth] = useMeasuredWidth<HTMLButtonElement>();
const setRoot = useCallback(
(node: HTMLDivElement | null) => {
wrapperRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref, wrapperRef],
);
const inputRef = useRef<HTMLInputElement>(null);
const pointerDownPos = useRef<{ x: number; y: number } | null>(null);
const isClickRef = useRef(true);
const fillAnimationRef = useRef<ReturnType<typeof animate> | null>(null);
const wrapperRectRef = useRef<DOMRect | null>(null);
const scaleRef = useRef(1);
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
normalizeValue(firstValue(defaultValue, safeMin), safeMin, safeMax, safeStep),
);
const [isDragging, setIsDragging] = useState(false);
const [isInteracting, setIsInteracting] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isValueEditable, setIsValueEditable] = useState(false);
const [isValueHovered, setIsValueHovered] = useState(false);
const [showInput, setShowInput] = useState(false);
const [inputValue, setInputValue] = useState("");
const rawValue = isControlled ? firstValue(value, safeMin) : uncontrolledValue;
const currentValue = normalizeValue(rawValue, safeMin, safeMax, safeStep);
const currentPercent = valueToPercent(currentValue, safeMin, safeMax);
const isActive = !disabled && (isInteracting || isHovered);
const fillPercent = useMotionValue(currentPercent);
const fillWidth = useTransform(fillPercent, (percent) => `${percent}%`);
const handleLeft = useTransform(fillPercent, (percent) => `max(5px, calc(${percent}% - 9px))`);
const rubberStretchPx = useMotionValue(0);
const rubberBandWidth = useTransform(
rubberStretchPx,
(stretch) => `calc(100% + ${Math.abs(stretch)}px)`,
);
const rubberBandX = useTransform(rubberStretchPx, (stretch) => (stretch < 0 ? stretch : 0));
useEffect(() => {
if (isControlled) return;
setUncontrolledValue((current) => normalizeValue(current, safeMin, safeMax, safeStep));
}, [isControlled, safeMax, safeMin, safeStep]);
useEffect(() => {
if (isInteracting || fillAnimationRef.current) return;
fillPercent.jump(currentPercent);
}, [currentPercent, fillPercent, isInteracting]);
useEffect(() => {
return () => {
fillAnimationRef.current?.stop();
};
}, []);
useEffect(() => {
if (!disabled) return;
setIsDragging(false);
setIsHovered(false);
setIsInteracting(false);
setIsValueEditable(false);
setIsValueHovered(false);
setShowInput(false);
rubberStretchPx.jump(0);
pointerDownPos.current = null;
}, [disabled, rubberStretchPx]);
useEffect(() => {
if (disabled || !editable || !isValueHovered || showInput || isValueEditable) {
if (!isValueHovered && !showInput) setIsValueEditable(false);
return undefined;
}
const timeout = window.setTimeout(() => setIsValueEditable(true), 800);
return () => window.clearTimeout(timeout);
}, [disabled, editable, isValueEditable, isValueHovered, showInput]);
useEffect(() => {
if (!showInput) return;
inputRef.current?.focus();
inputRef.current?.select();
}, [showInput]);
function setSliderValue(
nextValue: number,
options: {
animateFill?: boolean;
commit?: boolean;
fillValue?: number;
} = {},
) {
const normalized = normalizeValue(nextValue, safeMin, safeMax, safeStep);
const visualValue = options.fillValue ?? normalized;
const nextPercent = valueToPercent(visualValue, safeMin, safeMax);
if (fillAnimationRef.current) {
fillAnimationRef.current.stop();
fillAnimationRef.current = null;
}
if (options.animateFill) {
fillAnimationRef.current = animate(fillPercent, nextPercent, {
type: "spring",
stiffness: 300,
damping: 25,
mass: 0.8,
onComplete: () => {
fillAnimationRef.current = null;
},
});
} else {
fillPercent.jump(nextPercent);
}
if (!isControlled) setUncontrolledValue(normalized);
onValueChange?.([normalized]);
if (options.commit) {
onValueCommit?.([normalized]);
}
}
function positionToValue(clientX: number) {
const rect = wrapperRectRef.current;
const wrapper = wrapperRef.current;
if (!rect || !wrapper) return currentValue;
const screenX = clientX - rect.left;
const sceneX = screenX / scaleRef.current;
const nativeWidth = wrapper.offsetWidth || rect.width;
const percent = Math.max(0, Math.min(1, sceneX / nativeWidth));
return safeMin + percent * (safeMax - safeMin);
}
function computeRubberStretch(clientX: number, sign: number) {
const rect = wrapperRectRef.current;
if (!rect) return 0;
const distancePast = sign < 0 ? rect.left - clientX : clientX - rect.right;
const overflow = Math.max(0, distancePast - DEAD_ZONE);
return sign * MAX_STRETCH * Math.sqrt(Math.min(overflow / MAX_CURSOR_RANGE, 1));
}
function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (disabled || showInput) return;
event.preventDefault();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Synthetic or already-gone pointers can't be captured.
}
pointerDownPos.current = { x: event.clientX, y: event.clientY };
isClickRef.current = true;
setIsInteracting(true);
const wrapper = wrapperRef.current;
if (wrapper) {
wrapperRectRef.current = wrapper.getBoundingClientRect();
scaleRef.current = wrapperRectRef.current.width / wrapper.offsetWidth;
}
}
function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
if (disabled || !isInteracting || !pointerDownPos.current) return;
const dx = event.clientX - pointerDownPos.current.x;
const dy = event.clientY - pointerDownPos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (isClickRef.current && distance > CLICK_THRESHOLD) {
isClickRef.current = false;
setIsDragging(true);
}
if (isClickRef.current) return;
const rect = wrapperRectRef.current;
if (rect) {
if (event.clientX < rect.left) {
rubberStretchPx.jump(computeRubberStretch(event.clientX, -1));
} else if (event.clientX > rect.right) {
rubberStretchPx.jump(computeRubberStretch(event.clientX, 1));
} else {
rubberStretchPx.jump(0);
}
}
const nextValue = positionToValue(event.clientX);
setSliderValue(nextValue, { fillValue: nextValue });
}
function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
if (!isInteracting) return;
if (isClickRef.current) {
const rawNextValue = positionToValue(event.clientX);
const discreteSteps = (safeMax - safeMin) / safeStep;
const snappedValue =
discreteSteps <= 10
? normalizeValue(rawNextValue, safeMin, safeMax, safeStep)
: snapToDecile(rawNextValue, safeMin, safeMax);
setSliderValue(snappedValue, {
animateFill: true,
commit: true,
});
} else {
const finalValue = normalizeValue(positionToValue(event.clientX), safeMin, safeMax, safeStep);
setSliderValue(finalValue, {
commit: true,
});
}
if (rubberStretchPx.get() !== 0) {
animate(rubberStretchPx, 0, {
type: "spring",
visualDuration: 0.35,
bounce: 0.15,
});
}
setIsInteracting(false);
setIsDragging(false);
pointerDownPos.current = null;
}
function handlePointerCancel() {
if (!isInteracting) return;
setIsInteracting(false);
setIsDragging(false);
rubberStretchPx.jump(0);
pointerDownPos.current = null;
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
const largeStep = safeStep * 10;
let nextValue: number | undefined;
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
nextValue = currentValue - safeStep;
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
nextValue = currentValue + safeStep;
} else if (event.key === "PageDown") {
nextValue = currentValue - largeStep;
} else if (event.key === "PageUp") {
nextValue = currentValue + largeStep;
} else if (event.key === "Home") {
nextValue = safeMin;
} else if (event.key === "End") {
nextValue = safeMax;
}
if (nextValue === undefined) return;
event.preventDefault();
setSliderValue(nextValue, {
animateFill: true,
commit: true,
});
}
function closeInput() {
setShowInput(false);
setIsValueEditable(false);
setIsValueHovered(false);
}
function handleInputSubmit() {
const parsed = Number.parseFloat(inputValue);
if (!Number.isNaN(parsed)) {
setSliderValue(parsed, {
animateFill: true,
commit: true,
});
}
closeInput();
}
function openInput() {
setInputValue(currentValue.toFixed(decimalsForStep(safeStep)));
setShowInput(true);
}
function handleValueClick(event: MouseEvent<HTMLButtonElement>) {
if (!editable || !isValueEditable) return;
event.stopPropagation();
event.preventDefault();
openInput();
}
function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === "Enter") {
handleInputSubmit();
} else if (event.key === "Escape") {
closeInput();
}
}
function handleValueKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (!isValueEditable) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openInput();
}
}
const displayValue =
formatValue?.(currentValue) ?? `${currentValue.toFixed(decimalsForStep(safeStep))}${unit}`;
const ariaValueText =
typeof displayValue === "string" || typeof displayValue === "number"
? String(displayValue)
: String(currentValue);
const editLabel = typeof label === "string" ? `Edit ${label} value` : "Edit slider value";
const leftThreshold =
label && wrapperWidth > 0
? ((LABEL_LEFT + labelWidth + HANDLE_BUFFER) / wrapperWidth) * 100
: 0;
const rightThreshold =
showValue && wrapperWidth > 0
? ((wrapperWidth - VALUE_RIGHT - valueWidth - HANDLE_BUFFER) / wrapperWidth) * 100
: 100;
const valueDodge =
showValue &&
wrapperWidth > 0 &&
(currentPercent < leftThreshold || currentPercent > rightThreshold);
const handleOpacity = !isActive ? 0 : valueDodge ? 0.1 : isDragging ? 0.9 : 0.5;
const stepCount = Math.max(0, Math.round((safeMax - safeMin) / safeStep));
const hashMarks =
stepCount > 1 && stepCount <= 10
? Array.from({ length: stepCount - 1 }, (_, index) => ({
key: `step-${index + 1}`,
left: ((index + 1) / stepCount) * 100,
}))
: Array.from({ length: 9 }, (_, index) => ({
key: `decile-${index + 1}`,
left: (index + 1) * 10,
}));
return (
<div
aria-disabled={disabled || undefined}
aria-label={
ariaLabelledBy ? undefined : (ariaLabel ?? (typeof label === "string" ? label : "Slider"))
}
aria-labelledby={ariaLabelledBy}
aria-valuemax={safeMax}
aria-valuemin={safeMin}
aria-valuenow={currentValue}
aria-valuetext={ariaValueText}
data-active={isActive ? "true" : "false"}
data-disabled={disabled ? "true" : undefined}
data-slot="slider"
onKeyDown={handleKeyDown}
ref={setRoot}
role="slider"
tabIndex={disabled ? -1 : 0}
className={cn(
"group/slider relative h-10 w-full min-w-0 touch-none select-none outline-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
className,
)}
{...props}
>
<motion.div
data-slot="slider-track"
onMouseEnter={() => setIsHovered(!disabled)}
onMouseLeave={() => setIsHovered(false)}
onPointerCancel={handlePointerCancel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
width: rubberBandWidth,
x: rubberBandX,
}}
className="absolute inset-y-0 left-0 cursor-pointer overflow-hidden rounded-lg bg-control shadow-control transition-[box-shadow,opacity] duration-150 ease-out group-focus-visible/slider:ring-3 group-focus-visible/slider:ring-ring/50 group-data-[disabled=true]/slider:cursor-not-allowed"
>
<div
aria-hidden="true"
data-slot="slider-ticks"
className="pointer-events-none absolute inset-0 z-10"
>
{hashMarks.map((mark) => (
<span
className="absolute top-1/2 left-(--left) h-2 w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-transparent transition-colors duration-200 group-data-[active=true]/slider:bg-muted-foreground/20"
data-slot="slider-tick"
key={mark.key}
style={{ "--left": `${mark.left}%` } as CSSProperties}
/>
))}
</div>
<motion.div
aria-hidden="true"
data-slot="slider-range"
style={{ width: fillWidth }}
className="pointer-events-none absolute inset-y-0 left-0 bg-muted-foreground/10 transition-colors duration-150 ease-out group-data-[active=true]/slider:bg-muted-foreground/20"
/>
<motion.div
aria-hidden="true"
animate={{
opacity: handleOpacity,
scaleX: isActive ? 1 : 0.25,
scaleY: isActive && valueDodge ? 0.75 : 1,
}}
className="pointer-events-none absolute top-1/2 z-20 h-5 w-0.75 -translate-y-1/2 rounded-full bg-foreground"
data-slot="slider-thumb"
initial={false}
style={{ left: handleLeft }}
transition={{
opacity: { duration: 0.15 },
scaleX: { type: "spring", visualDuration: 0.25, bounce: 0.15 },
scaleY: { type: "spring", visualDuration: 0.2, bounce: 0.1 },
}}
/>
{label ? (
<span
className="pointer-events-none absolute top-1/2 left-2.5 z-30 inline-flex max-w-[calc(100%-4.5rem)] -translate-y-1/2 items-center truncate text-sm leading-none font-medium text-muted-foreground transition-colors duration-150 group-data-[active=true]/slider:text-foreground"
data-slot="slider-label"
ref={labelRef}
>
{label}
</span>
) : null}
{/* mt-px offsets the 1px underline so the value and input stay centered. */}
{showValue ? (
showInput ? (
<input
aria-label={editLabel}
className="absolute top-1/2 right-2.5 z-40 mt-px w-(--width) -translate-y-1/2 border-0 border-b border-muted-foreground bg-transparent p-0 pb-px font-mono text-sm leading-none font-medium text-foreground outline-none"
data-slot="slider-input"
onBlur={handleInputSubmit}
onChange={(event) => setInputValue(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={handleInputKeyDown}
onPointerDown={(event) => event.stopPropagation()}
ref={inputRef}
style={
{
"--width": `${Math.min(Math.max(inputValue.length, 3), 8)}ch`,
} as CSSProperties
}
type="text"
value={inputValue}
/>
) : (
<button
aria-label={editLabel}
className={cn(
"absolute top-1/2 right-2.5 z-30 mt-px -translate-y-1/2 cursor-default appearance-none border-0 border-b border-transparent bg-transparent p-0 pb-px text-right font-mono text-sm leading-none font-medium text-muted-foreground tabular-nums transition-[border-color,color] duration-150 group-data-[active=true]/slider:text-foreground",
isValueEditable && "cursor-text border-muted-foreground",
)}
data-slot="slider-value"
disabled={disabled}
onClick={handleValueClick}
onKeyDown={handleValueKeyDown}
onMouseEnter={() => {
if (!disabled) setIsValueHovered(true);
}}
onMouseLeave={() => setIsValueHovered(false)}
onPointerDown={(event) => {
if (isValueEditable) event.stopPropagation();
}}
ref={valueRef}
tabIndex={isValueEditable ? 0 : -1}
type="button"
>
{displayValue}
</button>
)
) : null}
</motion.div>
{name ? (
<input
disabled={disabled}
name={name}
readOnly
required={required}
type="hidden"
value={currentValue}
/>
) : null}
</div>
);
}