Slider With Input
Inputs & Forms

Slider With Input

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.

Install

npx shadcn@latest add @paragon/slider-with-input

Also installs: slider

slider-with-input.tsx

"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>
  );
}