Inputs

Scrub field

A number field you change by dragging its label or by typing arithmetic, as in design tools.

Built on
Pointer eventssetPointerCapturerole=spinbutton<input inputmode="decimal">:has()
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.4 kB
Dependencies
React
Code
import { ScrubField } from "@/components/equip/scrub-field";

export default function Demo() {
  return <ScrubField label="Width" defaultValue={320} min={0} unit="px" name="width" />;
}

Examples

Arithmetic

Enter evaluates the text. A leading operator works on the current value, so +10% adds a tenth and *2 doubles it.

Type 1200*1.2 or +10%

Code
import { ScrubField } from "@/components/equip/scrub-field";

export default function Example() {
  return (
    <div style={{ width: 240 }}>
      <ScrubField label="Max width" defaultValue={1200} min={0} unit="px" hint="Type 1200*1.2 or +10%" name="max-width" />
    </div>
  );
}

Bounds, step and precision

Dragging stops at 0 and 100. With a percent unit, a typed % is the unit, so +10% adds ten points.

Code
import { ScrubField } from "@/components/equip/scrub-field";

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 16, alignItems: "start" }}>
      <ScrubField label="Opacity" defaultValue={80} min={0} max={100} step={1} unit="%" name="opacity" />
      <ScrubField label="Duration" defaultValue={0.2} min={0} max={2} step={0.05} precision={2} unit="s" name="duration" />
    </div>
  );
}

Position and size row

A leading minus is relative, so type (-24) or 0-24 for a negative position.

Code
import { ScrubField } from "@/components/equip/scrub-field";

export default function Example() {
  const field = { width: 76 };
  const pair = { display: "flex", gap: 8 } as const;
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
      <div style={pair}>
        <ScrubField style={field} label="X" defaultValue={48} name="x" />
        <ScrubField style={field} label="Y" defaultValue={-24} name="y" />
      </div>
      <div style={pair}>
        <ScrubField style={field} label="W" defaultValue={320} min={0} name="w" />
        <ScrubField style={field} label="H" defaultValue={180} min={0} name="h" />
      </div>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label, tied to the input. It is also the drag handle.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
valuenumbernoneControlled value.
defaultValuenumbermin ?? 0Uncontrolled starting value.
onValueChange(value: number) => voidnoneCalled with each new value: per drag step, per key press and when typed text is committed.
minnumbernoneLowest value. Dragging and typing clamp to it, and it enables Home.
maxnumbernoneHighest value. Dragging and typing clamp to it, and it enables End.
stepnumber1Change per arrow key press or per 2px of drag.
unitstringnoneSuffix shown after the number, such as px or ms. Typed text may include it.
precisionnumberdecimals of stepDecimals shown and kept after every change.
...restComponentProps<"input">noneEvery native input attribute, including name, disabled, readOnly and ref. className and style go to the wrapper.

Anatomy

root.eq-scrub-fieldWrapper that carries className and style; data-scrubbing is set during a drag.
label.eq-scrub-field-labelThe native label and the drag handle.
label text.eq-scrub-field-label-text
grip.eq-scrub-field-gripLeft and right arrows that hint the label drags.
control.eq-scrub-field-controlThe bordered box holding the input and the unit.
input.eq-scrub-field-inputA text input with role spinbutton.
unit.eq-scrub-field-unitHidden from assistive tech; the unit is in aria-valuetext instead.
hint.eq-scrub-field-hint
status.eq-scrub-field-statusVisually hidden live region that reports text it could not read.

Keyboard

ArrowUpArrowDownChanges the value by one step, the same as dragging the label 2px.
Shift+ArrowUpShift+ArrowDownPageUpPageDownChanges the value by ten steps.
Alt+ArrowUpAlt+ArrowDownChanges the value by a tenth of a step, or the smallest step the precision shows.
HomeSets the minimum, when there is one.
EndSets the maximum, when there is one.
EnterEvaluates typed text, then lets the form submit.
EscapeDrops typed text and shows the last value again.

Notes

  • Drag the label left or right: every 2px is one step, Shift moves ten steps and Alt a tenth of a step.
  • A press on the label that moves less than 3px is a click and focuses the input, as a label should.
  • Typed text is evaluated on Enter or blur with plus, minus, times, divide, brackets, decimals and the unit, and never with eval.
  • A leading operator works on the current value: +10 adds, *2 doubles, +10% adds a tenth and -25% takes a quarter off.
  • Because a leading minus is relative, type (-24) or 0-24 to set a negative value.
  • When the unit is %, a typed percent sign is read as the unit, so +10% on an opacity of 50 gives 60.
  • Text it cannot read leaves the last value in place, marks the input invalid until the next edit and says so in a live region.
  • The input is type text with role spinbutton and aria-valuetext that includes the unit.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/scrub-field.tsx475 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type {
  ChangeEvent,
  ComponentProps,
  FocusEvent,
  KeyboardEvent,
  MouseEvent,
  PointerEvent,
  ReactNode,
} from "react";
import "./scrub-field.css";

export type ScrubFieldProps = Omit<
  ComponentProps<"input">,
  "type" | "value" | "defaultValue" | "min" | "max" | "step"
> & {
  /** Visible label. It is also the drag handle. */
  label: ReactNode;
  /** Help text under the control, linked with aria-describedby. */
  hint?: ReactNode;
  /** Controlled value. */
  value?: number;
  /** Uncontrolled starting value. Falls back to min, then 0. */
  defaultValue?: number;
  /** Called with every new value: per drag step, per key press and on commit. */
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  /** Change per arrow key press or per 2px of drag. */
  step?: number;
  /** Suffix shown after the number, such as "px" or "ms". Also accepted in typed text. */
  unit?: string;
  /** Decimals shown and kept. Defaults to the step's decimals. */
  precision?: number;
};

/** Pixels of horizontal drag per step. */
const PX_PER_STEP = 2;
/** Movement under this is a click, which focuses the input. */
const DRAG_THRESHOLD = 3;

function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

function decimals(n: number) {
  const text = String(n);
  if (text.includes("e-")) return Number(text.split("e-")[1]);
  const dot = text.indexOf(".");
  return dot === -1 ? 0 : text.length - dot - 1;
}

/* ---------- Arithmetic ----------
 * A small recursive-descent parser. Grammar:
 *   expr    := term (("+" | "-") term)*
 *   term    := unary (("*" | "/") unary)*
 *   unary   := ("+" | "-") unary | postfix
 *   postfix := primary "%"?
 *   primary := number unit? | "(" expr ")"
 * Returns null for anything it cannot read. Never uses eval.
 */
function evaluate(source: string, unit: string): number | null {
  const text = source;
  const unitLower = unit.toLowerCase();
  const percentIsUnit = unit === "%";
  let i = 0;

  function skipSpace() {
    while (i < text.length && /\s/.test(text[i])) i++;
  }

  function peek() {
    skipSpace();
    return text[i];
  }

  function skipUnit() {
    skipSpace();
    if (unit && text.slice(i, i + unit.length).toLowerCase() === unitLower) i += unit.length;
  }

  function primary(): number | null {
    const c = peek();
    if (c === "(") {
      i++;
      const inner = expr();
      if (inner === null || peek() !== ")") return null;
      i++;
      skipUnit();
      return inner;
    }
    const match = /^(\d+\.?\d*|\.\d+)/.exec(text.slice(i));
    if (!match) return null;
    i += match[0].length;
    skipUnit();
    return Number(match[0]);
  }

  function postfix(): number | null {
    const n = primary();
    if (n === null) return null;
    if (!percentIsUnit && peek() === "%") {
      i++;
      return n / 100;
    }
    return n;
  }

  function unary(): number | null {
    const c = peek();
    if (c === "+" || c === "-") {
      i++;
      const n = unary();
      return n === null ? null : c === "-" ? -n : n;
    }
    return postfix();
  }

  function term(): number | null {
    let left = unary();
    while (left !== null) {
      const c = peek();
      if (c !== "*" && c !== "/") break;
      i++;
      const right = unary();
      if (right === null) return null;
      left = c === "*" ? left * right : left / right;
    }
    return left;
  }

  function expr(): number | null {
    let left = term();
    while (left !== null) {
      const c = peek();
      if (c !== "+" && c !== "-") break;
      i++;
      const right = term();
      if (right === null) return null;
      left = c === "+" ? left + right : left - right;
    }
    return left;
  }

  const result = expr();
  skipSpace();
  if (result === null || i !== text.length || !Number.isFinite(result)) return null;
  return result;
}

/**
 * Reads typed text against the current value. A leading operator makes it relative:
 * "+10" adds, "*2" multiplies, "+10%" adds ten percent of the current value.
 */
function compute(raw: string, current: number, unit: string): number | null {
  const text = raw
    .trim()
    .replace(/−/g, "-")
    .replace(/×/g, "*")
    .replace(/÷/g, "/");
  if (text === "") return null;
  const op = text[0];
  if (op === "+" || op === "-" || op === "*" || op === "/") {
    const rest = text.slice(1);
    if ((op === "+" || op === "-") && unit !== "%" && rest.trim().endsWith("%")) {
      const pct = evaluate(rest.trim().slice(0, -1), unit);
      if (pct === null) return null;
      const delta = (current * pct) / 100;
      return op === "+" ? current + delta : current - delta;
    }
    const n = evaluate(rest, unit);
    if (n === null) return null;
    const out = op === "+" ? current + n : op === "-" ? current - n : op === "*" ? current * n : current / n;
    return Number.isFinite(out) ? out : null;
  }
  return evaluate(text, unit);
}

export function ScrubField({
  label,
  hint,
  value,
  defaultValue,
  onValueChange,
  min,
  max,
  step = 1,
  unit = "",
  precision: precisionProp,
  className,
  style,
  id,
  disabled,
  readOnly,
  onChange,
  onKeyDown,
  onBlur,
  "aria-describedby": describedBy,
  "aria-invalid": ariaInvalid,
  ...rest
}: ScrubFieldProps) {
  const autoId = useId();
  const inputId = id ?? autoId;
  const hintId = `${autoId}-hint`;
  const [inner, setInner] = useState<number>(defaultValue ?? min ?? 0);
  /** Text being typed; null when the field shows the committed value. */
  const [draft, setDraft] = useState<string | null>(null);
  const [invalid, setInvalid] = useState(false);
  const [status, setStatus] = useState("");
  const [scrubbing, setScrubbing] = useState(false);
  const current = value !== undefined ? value : inner;
  const precision = precisionProp ?? decimals(step);
  const locked = disabled || readOnly;

  /** The latest value, read by pointer handlers so a lagging controlled value never drops a step. */
  const latest = useRef(current);
  useEffect(() => {
    latest.current = current;
  }, [current]);

  const drag = useRef<{
    pointerId: number;
    startX: number;
    lastX: number;
    /** Unrounded running value, so fine steps add up even when precision hides them. */
    running: number;
    /** Pixels carried over that have not made a whole step yet. */
    carry: number;
    moved: boolean;
  } | null>(null);
  /** Set after a real drag so the click that follows does not focus the input. */
  const suppressClick = useRef(false);

  function clamp(n: number) {
    let next = n;
    if (min !== undefined) next = Math.max(min, next);
    if (max !== undefined) next = Math.min(max, next);
    return next;
  }

  function round(n: number) {
    return Number(n.toFixed(precision));
  }

  function emit(next: number) {
    const rounded = round(clamp(next));
    if (rounded === latest.current) return;
    latest.current = rounded;
    if (value === undefined) setInner(rounded);
    onValueChange?.(rounded);
  }

  function format(n: number) {
    return n.toFixed(precision);
  }

  /** Alt steps a tenth of the step, but never less than the precision can show. */
  function fineStep() {
    return Math.max(step / 10, 1 / 10 ** precision);
  }

  function commitDraft() {
    if (draft === null) return;
    // Text put back the way it was is not a new instruction; "-24" must not apply again.
    if (draft.trim() === format(current)) {
      setDraft(null);
      return;
    }
    const result = compute(draft, current, unit);
    setDraft(null);
    if (result === null) {
      setInvalid(true);
      setStatus(`Could not read that. Kept ${format(current)}${unit ? ` ${unit}` : ""}.`);
      return;
    }
    setInvalid(false);
    setStatus("");
    emit(result);
  }

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    onChange?.(event);
    setInvalid(false);
    setStatus("");
    setDraft(event.currentTarget.value);
  }

  function handleBlur(event: FocusEvent<HTMLInputElement>) {
    onBlur?.(event);
    commitDraft();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || locked) return;
    const by = event.shiftKey ? step * 10 : event.altKey ? fineStep() : step;
    const from = latest.current;
    function stepTo(n: number) {
      setDraft(null);
      setInvalid(false);
      setStatus("");
      emit(n);
    }
    switch (event.key) {
      case "ArrowUp":
        stepTo(from + by);
        break;
      case "ArrowDown":
        stepTo(from - by);
        break;
      case "PageUp":
        stepTo(from + step * 10);
        break;
      case "PageDown":
        stepTo(from - step * 10);
        break;
      case "Home":
        if (min === undefined) return;
        stepTo(min);
        break;
      case "End":
        if (max === undefined) return;
        stepTo(max);
        break;
      case "Enter":
        if (draft === null) return;
        commitDraft();
        // Let Enter go on to submit a surrounding form.
        return;
      case "Escape":
        if (draft === null && !invalid) return;
        setDraft(null);
        setInvalid(false);
        setStatus("");
        break;
      default:
        return;
    }
    event.preventDefault();
    event.stopPropagation();
  }

  /* ---------- Scrub on the label ---------- */

  function handlePointerDown(event: PointerEvent<HTMLLabelElement>) {
    if (locked || event.button !== 0) return;
    // Keeps text from being selected and focus where it is while dragging.
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    drag.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      lastX: event.clientX,
      running: latest.current,
      carry: 0,
      moved: false,
    };
  }

  function handlePointerMove(event: PointerEvent<HTMLLabelElement>) {
    const d = drag.current;
    if (!d || d.pointerId !== event.pointerId) return;
    if (!d.moved) {
      if (Math.abs(event.clientX - d.startX) < DRAG_THRESHOLD) return;
      d.moved = true;
      d.lastX = d.startX;
      setDraft(null);
      setInvalid(false);
      setScrubbing(true);
    }
    const px = d.carry + (event.clientX - d.lastX);
    d.lastX = event.clientX;
    const steps = Math.trunc(px / PX_PER_STEP);
    d.carry = px - steps * PX_PER_STEP;
    if (steps === 0) return;
    const per = event.shiftKey ? step * 10 : event.altKey ? step / 10 : step;
    d.running = clamp(d.running + steps * per);
    emit(d.running);
  }

  function endDrag(event: PointerEvent<HTMLLabelElement>) {
    const d = drag.current;
    if (!d || d.pointerId !== event.pointerId) return;
    suppressClick.current = d.moved;
    drag.current = null;
    setScrubbing(false);
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
  }

  function handleLabelClick(event: MouseEvent<HTMLLabelElement>) {
    // A real drag ends in a click; it should not move focus into the input.
    if (suppressClick.current) {
      suppressClick.current = false;
      event.preventDefault();
    }
  }

  const text = draft ?? format(current);
  const valueText = `${format(current)}${unit ? ` ${unit}` : ""}`;

  return (
    <div
      className={["eq-scrub-field", className].filter(Boolean).join(" ")}
      style={style}
      data-scrubbing={scrubbing || undefined}
    >
      <label
        className="eq-scrub-field-label"
        htmlFor={inputId}
        data-disabled={locked || undefined}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onLostPointerCapture={endDrag}
        onClick={handleLabelClick}
      >
        <span className="eq-scrub-field-label-text">{label}</span>
        <svg
          className="eq-scrub-field-grip"
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="M5 5 2 8l3 3M11 5l3 3-3 3M2 8h12" />
        </svg>
      </label>
      <div className="eq-scrub-field-control" data-disabled={disabled || undefined}>
        <input
          inputMode="decimal"
          autoComplete="off"
          spellCheck={false}
          {...rest}
          id={inputId}
          className="eq-scrub-field-input"
          type="text"
          role="spinbutton"
          aria-valuenow={current}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuetext={unit ? valueText : undefined}
          aria-describedby={[describedBy, present(hint) ? hintId : null].filter(Boolean).join(" ") || undefined}
          aria-invalid={invalid || ariaInvalid || undefined}
          disabled={disabled}
          readOnly={readOnly}
          value={text}
          onChange={handleChange}
          onBlur={handleBlur}
          onKeyDown={handleKeyDown}
        />
        {unit && (
          <span className="eq-scrub-field-unit" aria-hidden="true">
            {unit}
          </span>
        )}
      </div>
      {present(hint) && (
        <p className="eq-scrub-field-hint" id={hintId}>
          {hint}
        </p>
      )}
      <span className="eq-scrub-field-status" role="status">
        {status}
      </span>
    </div>
  );
}
components/equip/scrub-field.css162 lines
@layer equip {
  .eq-scrub-field {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    justify-items: start;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  /* The label is the drag handle: 24px tall so it is a fair target, and it never selects text. */
  .eq-scrub-field-label {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-height: 24px;
    max-width: 100%;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
    cursor: ew-resize;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    transition: scale var(--eq-dur) var(--eq-ease);
  }

  /* The handle gives under the finger and stays given for the whole drag. The value itself
     follows the pointer exactly and never animates. */
  .eq-scrub-field-label:active:not([data-disabled]),
  .eq-scrub-field[data-scrubbing] .eq-scrub-field-label {
    scale: var(--eq-press);
  }

  .eq-scrub-field-label-text {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-scrub-field-grip {
    flex: none;
    width: 12px;
    height: 12px;
    color: var(--eq-ink-3);
    transition: color var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-scrub-field-label:hover:not([data-disabled]) .eq-scrub-field-grip {
      color: var(--eq-ink);
    }
  }

  .eq-scrub-field[data-scrubbing] .eq-scrub-field-grip {
    color: var(--eq-ink);
  }

  .eq-scrub-field-label[data-disabled] {
    cursor: not-allowed;
  }

  .eq-scrub-field-hint {
    margin: 0;
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  /* The bordered box is the control the eye sees; the input inside is bare. */
  .eq-scrub-field-control {
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: var(--eq-space-1);
    width: calc(var(--eq-h-m) * 4);
    max-width: 100%;
    height: var(--eq-h-m);
    padding-inline: var(--eq-space-3);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    /* :where keeps this below the invalid rule, so a red border stays red on hover. */
    .eq-scrub-field-control:hover:where(:not([data-disabled])) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

  /* While scrubbing, the control shows it is the thing changing. */
  .eq-scrub-field[data-scrubbing] .eq-scrub-field-control {
    border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 45%);
  }

  .eq-scrub-field-control:has(.eq-scrub-field-input:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-scrub-field-control:has(.eq-scrub-field-input[aria-invalid="true"]) {
    border-color: var(--eq-danger);
  }

  .eq-scrub-field-input {
    flex: 1 1 auto;
    width: 100%;
    min-width: 0;
    height: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    color: inherit;
    font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
    font-variant-numeric: tabular-nums;
  }

  .eq-scrub-field-input:focus-visible {
    outline: none;
  }

  .eq-scrub-field-input::placeholder {
    color: var(--eq-ink-3);
    opacity: 1;
  }

  .eq-scrub-field-unit {
    flex: none;
    color: var(--eq-ink-3);
    font: var(--eq-weight) var(--eq-text-s) / 1 var(--eq-font);
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-scrub-field-control[data-disabled] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-scrub-field-control[data-disabled] > * {
    cursor: not-allowed;
  }

  .eq-scrub-field:has(.eq-scrub-field-control[data-disabled]) .eq-scrub-field-label {
    opacity: 0.45;
  }

  /* Read by screen readers, never seen. */
  .eq-scrub-field-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index