Number Scrubber
Inputs & Forms

Number Scrubber

A Figma-style numeric field whose label scrubs the value on horizontal drag with pointer capture, Shift x10 and Alt x0.1 modifiers, direction-aware digit rolls, and click-to-type entry.

Install

npx shadcn@latest add @paragon/number-scrubber

number-scrubber.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronsLeftRight } from "lucide-react";
import { cn } from "@/lib/utils";

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

function clamp(v: number, min?: number, max?: number) {
  if (min !== undefined && v < min) return min;
  if (max !== undefined && v > max) return max;
  return v;
}

function decimalsOf(n: number) {
  const s = String(n);
  const i = s.indexOf(".");
  return i === -1 ? 0 : s.length - i - 1;
}

function roundTo(v: number, decimals: number) {
  const p = 10 ** decimals;
  return Math.round(v * p) / p;
}

/** One glyph cell: the old char rolls out while the new rolls in. */
function DigitCell({
  char,
  direction,
  reduced,
}: {
  char: string;
  direction: 1 | -1;
  reduced: boolean;
}) {
  return (
    <span className="relative inline-flex justify-center overflow-hidden">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={char}
          initial={
            reduced
              ? { opacity: 0 }
              : { y: direction > 0 ? "100%" : "-100%", opacity: 0.25 }
          }
          animate={{ y: "0%", opacity: 1 }}
          exit={
            reduced
              ? { opacity: 0, transition: { duration: 0.1 } }
              : {
                  y: direction > 0 ? "-100%" : "100%",
                  opacity: 0.25,
                  transition: { duration: 0.12, ease: EASE_EXIT },
                }
          }
          transition={{ duration: 0.12, ease: EASE_OUT }}
          className="inline-block"
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export interface NumberScrubberProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** Field label — also the scrub handle. */
  label: string;
  /** Controlled value. */
  value?: number;
  /** Initial value when uncontrolled. */
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  /** Value change per step. Shift multiplies by 10, Alt by 0.1. */
  step?: number;
  /** Horizontal pixels of drag per step — higher is finer. */
  pixelsPerStep?: number;
  /** Unit suffix rendered after the value, e.g. "px" or "%". */
  unit?: string;
  /** Form field name; renders a hidden input. */
  name?: string;
  disabled?: boolean;
  /** Disables the digit-roll motion. */
  static?: boolean;
}

/**
 * A Figma-style numeric field: drag the label horizontally to scrub the value
 * (pointer capture keeps the drag alive outside the field; Shift steps x10,
 * Alt x0.1), changed digits roll direction-aware, and a double-click — or just
 * typing while focused — switches to raw text entry.
 */
export function NumberScrubber({
  label,
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  min,
  max,
  step = 1,
  pixelsPerStep = 2,
  unit,
  name,
  disabled = false,
  static: isStatic = false,
  className,
  ...props
}: NumberScrubberProps) {
  const reduced = useReducedMotion() ?? false;
  const id = React.useId();
  const labelId = `${id}-label`;

  const [uncontrolled, setUncontrolled] = React.useState(
    clamp(defaultValue, min, max),
  );
  const value = valueProp ?? uncontrolled;
  const valueRef = React.useRef(value);
  valueRef.current = value;

  const [scrubbing, setScrubbing] = React.useState(false);
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState("");

  const directionRef = React.useRef<1 | -1>(1);
  const spinRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const drag = React.useRef({ lastX: 0, acc: 0, moved: 0 });

  // Show at most one decimal finer than `step`, so Alt-scrubbing stays legible.
  const maxDecimals = Math.min(decimalsOf(step) + 1, 4);

  const setValue = React.useCallback(
    (next: number) => {
      const clamped = clamp(next, min, max);
      if (clamped === valueRef.current) return;
      directionRef.current = clamped > valueRef.current ? 1 : -1;
      if (valueProp === undefined) setUncontrolled(clamped);
      onValueChange?.(clamped);
    },
    [min, max, valueProp, onValueChange],
  );

  const startEdit = (seed?: string) => {
    if (disabled) return;
    setDraft(seed ?? String(value));
    setEditing(true);
  };

  React.useEffect(() => {
    if (!editing) return;
    const input = inputRef.current;
    if (!input) return;
    input.focus();
    input.select();
  }, [editing]);

  const commitEdit = () => {
    const parsed = Number.parseFloat(draft);
    if (!Number.isNaN(parsed)) setValue(roundTo(parsed, maxDecimals));
    setEditing(false);
    spinRef.current?.focus();
  };

  const stepBy = (dir: 1 | -1, mult: number) => {
    setValue(roundTo(valueRef.current + dir * step * mult, maxDecimals));
  };

  // ---- Label scrubbing --------------------------------------------------
  const onLabelPointerDown = (event: React.PointerEvent<HTMLSpanElement>) => {
    if (disabled || editing || event.button !== 0) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    drag.current = { lastX: event.clientX, acc: valueRef.current, moved: 0 };
    setScrubbing(true);
    document.documentElement.style.cursor = "ew-resize";
    document.documentElement.style.userSelect = "none";
  };

  const onLabelPointerMove = (event: React.PointerEvent<HTMLSpanElement>) => {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    const dx = event.clientX - drag.current.lastX;
    if (dx === 0) return;
    drag.current.lastX = event.clientX;
    drag.current.moved += Math.abs(dx);
    const mult = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
    drag.current.acc += (dx / pixelsPerStep) * step * mult;
    drag.current.acc = clamp(drag.current.acc, min, max);
    setValue(roundTo(drag.current.acc, maxDecimals));
  };

  const onLabelPointerUp = (event: React.PointerEvent<HTMLSpanElement>) => {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    event.currentTarget.releasePointerCapture(event.pointerId);
    setScrubbing(false);
    document.documentElement.style.cursor = "";
    document.documentElement.style.userSelect = "";
    // A press without a real drag reads as a click — move focus to the value.
    if (drag.current.moved < 3) spinRef.current?.focus();
  };

  React.useEffect(() => {
    return () => {
      document.documentElement.style.cursor = "";
      document.documentElement.style.userSelect = "";
    };
  }, []);

  // ---- Keyboard on the value --------------------------------------------
  const onSpinKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const mult = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
    if (event.key === "ArrowUp") {
      event.preventDefault();
      stepBy(1, mult);
    } else if (event.key === "ArrowDown") {
      event.preventDefault();
      stepBy(-1, mult);
    } else if (event.key === "Home" && min !== undefined) {
      event.preventDefault();
      setValue(min);
    } else if (event.key === "End" && max !== undefined) {
      event.preventDefault();
      setValue(max);
    } else if (event.key === "Enter") {
      event.preventDefault();
      startEdit();
    } else if (/^[0-9.\-]$/.test(event.key) && !event.metaKey && !event.ctrlKey) {
      // Typing a digit drops straight into edit mode, seeded with it.
      event.preventDefault();
      startEdit(event.key);
    }
  };

  const display = String(roundTo(value, maxDecimals));
  const chars = display.split("");

  return (
    <div
      data-slot="number-scrubber"
      data-scrubbing={scrubbing || undefined}
      className={cn(
        "flex h-9 w-full min-w-0 items-center rounded-lg border border-input bg-transparent",
        "transition-[border-color,box-shadow] duration-150 ease-out",
        "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
        scrubbing && "border-ring ring-[3px] ring-ring/25",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      <span
        id={labelId}
        onPointerDown={onLabelPointerDown}
        onPointerMove={onLabelPointerMove}
        onPointerUp={onLabelPointerUp}
        onPointerCancel={onLabelPointerUp}
        onDoubleClick={() => startEdit()}
        className={cn(
          "group/handle relative flex h-full shrink-0 cursor-ew-resize touch-none items-center gap-1 pr-1.5 pl-2.5 text-xs select-none",
          "text-muted-foreground transition-[color] duration-150 ease-out",
          "after:absolute after:top-1/2 after:left-1/2 after:h-10 after:w-full after:min-w-10 after:-translate-1/2",
          scrubbing && "text-foreground",
        )}
        title="Drag to adjust — Shift ×10, Alt ×0.1"
      >
        {label}
        <ChevronsLeftRight
          aria-hidden
          className={cn(
            "size-3 text-muted-foreground/50 transition-opacity duration-150 ease-out",
            "opacity-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/handle:opacity-100",
            scrubbing && "opacity-100",
          )}
        />
      </span>

      {editing ? (
        <input
          ref={inputRef}
          type="text"
          inputMode="decimal"
          aria-labelledby={labelId}
          value={draft}
          onChange={(event) =>
            setDraft(event.target.value.replace(/[^0-9.\-]/g, ""))
          }
          onBlur={commitEdit}
          onKeyDown={(event) => {
            if (event.key === "Enter") {
              event.preventDefault();
              commitEdit();
            } else if (event.key === "Escape") {
              event.preventDefault();
              setEditing(false);
              spinRef.current?.focus();
            }
          }}
          className="h-full min-w-0 flex-1 bg-transparent pr-2 text-sm font-medium text-foreground tabular-nums outline-none"
        />
      ) : (
        <div
          ref={spinRef}
          role="spinbutton"
          tabIndex={disabled ? -1 : 0}
          aria-valuenow={value}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuetext={unit ? `${display}${unit}` : display}
          aria-labelledby={labelId}
          aria-disabled={disabled || undefined}
          onKeyDown={onSpinKeyDown}
          onDoubleClick={() => startEdit()}
          onClick={() => startEdit()}
          className="flex h-full min-w-0 flex-1 cursor-text items-center pr-2 text-sm font-medium text-foreground tabular-nums outline-none"
        >
          <span aria-hidden className="flex">
            {chars.map((char, i) =>
              /\d/.test(char) ? (
                <DigitCell
                  // Keyed from the layout position so cells reconcile in place.
                  key={`d-${chars.length}-${i}`}
                  char={char}
                  direction={directionRef.current}
                  reduced={reduced || isStatic}
                />
              ) : (
                <span key={`c-${chars.length}-${i}`} className="inline-block">
                  {char}
                </span>
              ),
            )}
          </span>
        </div>
      )}

      {unit && (
        <span
          aria-hidden
          className="shrink-0 pr-2.5 text-xs text-muted-foreground select-none"
        >
          {unit}
        </span>
      )}
      {name && <input type="hidden" name={name} value={display} />}
    </div>
  );
}