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.
npx shadcn@latest add @paragon/number-scrubber"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>
);
}