/Inputs
Inputs • React
Scrub Number Field
A number field you drag to scrub and click to type, with a ruler that tracks the gesture.
Installation
Terminal
npx shadcn@latest add @microkit/scrub-number-fieldCode
1"use client";23import { useRef, useState } from "react";4import type { AnimationEvent as ReactAnimationEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";56const MIN = 0;7const MAX = 100;8const PIXELS_PER_STEP = 2;9const DRAG_THRESHOLD = 3;1011/* The two speeds a design tool trains your hands to expect, on the keys it trains them to reach for. */12const stepFor = (event: { altKey: boolean; shiftKey: boolean }) => (event.shiftKey ? 10 : event.altKey ? 0.1 : 1);13const round = (value: number) => Math.round(value * 10) / 10;14const clamp = (value: number) => Math.min(MAX, Math.max(MIN, value));15const numeric = (raw: string) => raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1");1617const nudgeKeyframes = `18@keyframes scrub-number-nudge {190%, 100% { transform: translateX(0); }2040% { transform: translateX(var(--scrub-nudge)); }21}22@media (prefers-reduced-motion: reduce) {23@keyframes scrub-number-nudge {240%, 100% { transform: none; }25}26}`;2728export function ScrubNumberField() {29const [value, setValue] = useState(48);30const [draft, setDraft] = useState<string | null>(null);31const [scrubbing, setScrubbing] = useState(false);32const [bound, setBound] = useState<"min" | "max" | null>(null);33const inputRef = useRef<HTMLInputElement>(null);34const drag = useRef({ origin: 0, base: 48, value: 48, step: 1, pinned: "", moved: false });35const reverting = useRef(false);3637const startScrub = (event: ReactPointerEvent<HTMLDivElement>) => {38/* Once you are typing the field is a text input again, and dragging selects characters the way it should. */39if (event.button !== 0 || document.activeElement === inputRef.current) return;40event.preventDefault();41event.currentTarget.setPointerCapture(event.pointerId);42drag.current = { origin: event.clientX, base: value, value, step: stepFor(event), pinned: "", moved: false };43};4445const moveScrub = (event: ReactPointerEvent<HTMLDivElement>) => {46if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;4748/* Reaching for Shift mid-drag re-bases the gesture instead of multiplying the pixels already travelled. */49const held = stepFor(event);50if (held !== drag.current.step) {51drag.current = { ...drag.current, origin: event.clientX, base: drag.current.value, step: held, moved: true };52}5354const travel = event.clientX - drag.current.origin;55if (!drag.current.moved && Math.abs(travel) < DRAG_THRESHOLD) return;56drag.current.moved = true;57setScrubbing(true);5859const wanted = round(drag.current.base + Math.round(travel / PIXELS_PER_STEP) * held);60const pinned = wanted > MAX ? "max" : wanted < MIN ? "min" : "";61if (pinned && pinned !== drag.current.pinned) setBound(pinned);62drag.current.pinned = pinned;63drag.current.value = clamp(wanted);64setValue(drag.current.value);65};6667const endScrub = (event: ReactPointerEvent<HTMLDivElement>) => {68if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;69event.currentTarget.releasePointerCapture(event.pointerId);70setScrubbing(false);71/* A press that never travelled is a click, and a click hands the field over for typing. */72if (!drag.current.moved) {73inputRef.current?.focus();74inputRef.current?.select();75}76};7778const settle = (next: number) => {79const clamped = clamp(round(next));80if (clamped !== round(next)) setBound(clamped === MIN ? "min" : "max");81drag.current.value = clamped;82setValue(clamped);83setDraft(null);84};8586const commit = () => {87const parsed = draft === null ? Number.NaN : Number.parseFloat(draft);88/* Escape blurs the field, and the blur that follows must not commit the draft Escape just threw away. */89if (!reverting.current && Number.isFinite(parsed)) settle(parsed);90reverting.current = false;91setDraft(null);92};9394const handleKey = (event: ReactKeyboardEvent<HTMLInputElement>) => {95if (event.key === "Enter") {96event.preventDefault();97commit();98event.currentTarget.blur();99return;100}101if (event.key === "Escape") {102reverting.current = true;103event.currentTarget.blur();104return;105}106const direction = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;107if (!direction) return;108event.preventDefault();109settle(value + direction * stepFor(event));110};111112/* The nudge clears itself when it finishes, so a bound can be hit again the moment the field is still. */113const clearBound = (event: ReactAnimationEvent<HTMLDivElement>) => {114if (event.animationName === "scrub-number-nudge") setBound(null);115};116117return (118<>119<style>{nudgeKeyframes}</style>120<div121className="flex h-8 w-[96px] cursor-ew-resize touch-none items-center rounded-[7px] border border-[light-dark(#cbd2dc,#363a42)] bg-[light-dark(#ffffff,#15171b)] px-[10px] transition-[border-color,box-shadow] duration-200 ease-[ease] [--scrub-nudge:5px] focus-within:cursor-text focus-within:border-[#f97316] focus-within:shadow-[0_0_0_3px_#f9731625] data-[scrubbing]:border-[#f97316] data-[scrubbing]:shadow-[0_0_0_3px_#f9731625] data-[bound]:border-[#f97316] data-[bound]:[animation:scrub-number-nudge_.26s_cubic-bezier(.22,1,.36,1)] data-[bound=min]:[--scrub-nudge:-5px]"122data-scrubbing={scrubbing || undefined}123data-bound={bound ?? undefined}124onAnimationEnd={clearBound}125onPointerDown={startScrub}126onPointerMove={moveScrub}127onPointerUp={endScrub}128onPointerCancel={endScrub}129>130<input131ref={inputRef}132className="w-full min-w-0 cursor-[inherit] border-0 bg-transparent p-0 font-mono text-[13px] font-medium leading-none tabular-nums text-[light-dark(#262d38,#e8ebee)] outline-0 focus:cursor-text focus:outline-0 focus-visible:outline-0"133type="text"134inputMode="decimal"135autoComplete="off"136aria-label="Opacity"137value={draft ?? String(value)}138onChange={(event) => setDraft(numeric(event.target.value))}139onKeyDown={handleKey}140onBlur={commit}141/>142</div>143</>144);145}