A slider and numeric field bound to one value — drag for coarse, type for exact — with clamp-and-snap commits that flash the ring when a correction happens.
npx shadcn@latest add @paragon/slider-with-inputAlso installs: slider
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Slider } from "@/registry/paragon/ui/slider";
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v));
}
function snap(v: number, min: number, step: number) {
const snapped = min + Math.round((v - min) / step) * step;
// Avoid float dust like 0.30000000000000004.
return Number(snapped.toFixed(6));
}
export interface SliderWithInputProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Visible label, wired to the numeric input; also names the slider. */
label: string;
/** Controlled value. */
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/** Suffix inside the numeric field, e.g. "GB", "req/min". */
unit?: string;
/** Tick marks under the slider fill. */
ticks?: number[];
/** Show min/max captions under the track. */
showRange?: boolean;
/** Form field name; renders a hidden input. */
name?: string;
disabled?: boolean;
}
/**
* A slider and a numeric field bound to one value — drag for coarse, type
* for exact. The field drafts freely while focused and commits on blur or
* Enter with clamp-and-snap; commits that had to be clamped flash the
* field's ring once so the correction is legible, not silent. Arrows step
* (Shift ×10) from either control.
*/
export function SliderWithInput({
label,
value: valueProp,
defaultValue,
onValueChange,
min = 0,
max = 100,
step = 1,
unit,
ticks,
showRange = false,
name,
disabled = false,
className,
...props
}: SliderWithInputProps) {
const inputId = React.useId();
const [uncontrolled, setUncontrolled] = React.useState(
clamp(defaultValue ?? min, min, max),
);
const value = clamp(valueProp ?? uncontrolled, min, max);
const [draft, setDraft] = React.useState<string | null>(null);
const [clampFlash, setClampFlash] = React.useState(false);
const flashTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (flashTimer.current) clearTimeout(flashTimer.current);
};
}, []);
const setValue = React.useCallback(
(next: number) => {
const finished = clamp(snap(next, min, step), min, max);
if (valueProp === undefined) setUncontrolled(finished);
onValueChange?.(finished);
return finished;
},
[valueProp, onValueChange, min, max, step],
);
const flashClamp = () => {
setClampFlash(true);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setClampFlash(false), 500);
};
const commitDraft = () => {
if (draft === null) return;
const parsed = Number.parseFloat(draft);
if (!Number.isNaN(parsed)) {
const committed = setValue(parsed);
if (parsed < min || parsed > max) flashClamp();
void committed;
}
setDraft(null);
};
const stepBy = (direction: 1 | -1, multiplier = 1) => {
setDraft(null);
setValue(value + direction * step * multiplier);
};
return (
<div
data-slot="slider-with-input"
className={cn("w-full", disabled && "pointer-events-none opacity-50", className)}
{...props}
>
<div className="mb-2.5 flex items-center justify-between gap-3">
<label
htmlFor={inputId}
className="min-w-0 truncate text-sm font-medium text-foreground"
>
{label}
</label>
<div
className={cn(
"flex h-8 w-fit shrink-0 items-center rounded-md border bg-transparent",
"transition-[border-color,box-shadow] duration-150 ease-out",
clampFlash
? "border-warning ring-[3px] ring-warning/25"
: "border-input focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
)}
>
<input
id={inputId}
type="text"
inputMode="decimal"
autoComplete="off"
disabled={disabled}
value={draft ?? String(value)}
onChange={(event) =>
setDraft(event.target.value.replace(/[^0-9.\-]/g, ""))
}
onFocus={(event) => {
setDraft(String(value));
event.currentTarget.select();
}}
onBlur={commitDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitDraft();
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(null);
} else if (event.key === "ArrowUp") {
event.preventDefault();
stepBy(1, event.shiftKey ? 10 : 1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
stepBy(-1, event.shiftKey ? 10 : 1);
}
}}
className={cn(
"h-full w-16 min-w-0 bg-transparent px-2 text-right text-sm font-medium text-foreground tabular-nums outline-none",
!unit && "pr-2",
)}
/>
{unit && (
<span className="pr-2 pl-0.5 text-xs text-muted-foreground select-none">
{unit}
</span>
)}
</div>
</div>
<Slider
aria-label={label}
min={min}
max={max}
step={step}
value={[value]}
onValueChange={(next) => {
setDraft(null);
setValue(next[0] ?? value);
}}
ticks={ticks}
showTooltip={false}
disabled={disabled}
/>
{showRange && (
<div className="mt-1.5 flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
<span>
{min}
{unit ? ` ${unit}` : ""}
</span>
<span>
{max}
{unit ? ` ${unit}` : ""}
</span>
</div>
)}
{name && <input type="hidden" name={name} value={value} />}
</div>
);
}