trove/cn

Scrub Field

One control with two regions: drag the label to scrub the value under pointer lock, or click the number and type. The cursor never runs off the edge.

Basic

Press the label and pull sideways. Hold Alt to scrub fine, Shift to scrub coarse, or click the field and type.

basic

Property panel

The job it was built for. A stack of identical controls, where dragging the label beats typing for every value.

Layout

property-panel

Units and formatting

Intl formatting handles percentages and currency; `suffix` covers units Intl has no style for. Lower sensitivity suits a 0–1 range.

formats

Vertical scrub

The same field on each axis. `direction="vertical"` pulls up to increase, and the handle's cursor and arrow both turn to match — an arrow pointing across the axis you're dragging reads as a bug.

vertical

Install

Install the packages this component imports.

bun add @base-ui/react lucide-react
Terminal

Also requires lib/utils.ts (see Installation).

Source

Copy and paste the following code into your project.

"use client";

/**
 * ScrubField — a number input whose *label* is the drag handle. Press the
 * label and pull sideways and the value scrubs under pointer lock, so the
 * cursor never leaves the field or runs off the screen edge. Alt scrubs
 * fine, Shift scrubs coarse. The input itself still types and steps like
 * any other number input.
 *
 * Two departures from the house conventions, both deliberate.
 *
 * 1. Hand-composed on `@base-ui/react/number-field` rather than scaffolded
 *    with `npx shadcn add`. There is no `base-nova` scaffold for it, and the
 *    label-as-handle layout is not what a generic number-field scaffold
 *    produces.
 * 2. No Motion. The value has to track the pointer 1:1, since a scrub that
 *    lags the cursor reads as broken, and everything else here is a hover
 *    wash or a colour change. Those are CSS transitions on the house
 *    `duration-*` tokens, which is cheaper than a Motion component that
 *    would only animate opacity. `MotionConfig` cannot reach CSS, so the
 *    one bit of travel is gated with `motion-safe:` — on the rule that
 *    applies the nudge, not as a `motion-reduce:` override, which loses the
 *    specificity contest against the `group-data-scrubbing` selector.
 */

import { type ReactNode, useId } from "react";
import { NumberField } from "@base-ui/react/number-field";
import { MoveHorizontal, MoveVertical } from "lucide-react";

import { cn } from "@/lib/utils";

interface ScrubFieldProps extends Omit<
  NumberField.Root.Props,
  "className" | "style" | "render" | "id"
> {
  /** The drag handle. Pressing it and moving sideways scrubs the value. */
  label: ReactNode;
  /** Static unit shown inside the field, for units Intl has no format for ("px", "ms"). */
  suffix?: string;
  /** Pixels the pointer must travel before the value moves. Higher is less sensitive. */
  pixelSensitivity?: number;
  /** Axis the scrub follows. */
  direction?: "horizontal" | "vertical";
  className?: string;
  inputClassName?: string;
}

function ScrubField({
  label,
  suffix,
  pixelSensitivity = 2,
  direction = "horizontal",
  className,
  inputClassName,
  ...props
}: ScrubFieldProps) {
  const id = useId();
  const isVertical = direction === "vertical";
  const scrubCursor = isVertical ? "cursor-ns-resize" : "cursor-ew-resize";
  // The affordance icon and its scrub nudge both follow the drag axis — an
  // arrow pointing across the direction you're pulling reads as a bug.
  const ScrubIcon = isVertical ? MoveVertical : MoveHorizontal;
  const scrubNudge = isVertical
    ? "motion-safe:group-data-scrubbing/scrub:translate-y-0.5"
    : "motion-safe:group-data-scrubbing/scrub:translate-x-0.5";

  return (
    <NumberField.Root data-slot="scrub-field" id={id} {...props}>
      {/* One box with two regions: the left scrubs, the right types. Fixed
          width by default so a stack of fields aligns without the caller
          having to size anything. */}
      <NumberField.Group
        data-slot="scrub-field-group"
        className={cn(
          "flex items-stretch overflow-hidden rounded-md border border-input bg-background",
          // Fixed so a stack aligns without the caller sizing anything.
          "w-40",
          // Not `transition-colors`: that list has no `box-shadow`, so the
          // focus ring would land instantly while the border eased in behind
          // it — two halves of one focus treatment arriving apart.
          "transition-[border-color,box-shadow] duration-quick",
          "data-disabled:cursor-not-allowed data-disabled:opacity-50",
          "data-scrubbing:border-ring/60",
          "has-[input:focus-visible]:border-ring/60 has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50",
          className,
        )}
      >
        <NumberField.ScrubArea
          data-slot="scrub-field-area"
          direction={direction}
          pixelSensitivity={pixelSensitivity}
          className={cn(
            "group/scrub flex items-center gap-1 py-1 pr-1.5 pl-2 select-none",
            "transition-colors duration-fast hover:bg-hover data-scrubbing:bg-active",
            scrubCursor,
            "data-disabled:cursor-not-allowed",
          )}
        >
          {/* The "you can drag this" tell. Faint at rest so a still frame
              still shows the affordance, full strength on approach. */}
          <ScrubIcon
            aria-hidden
            className={cn(
              "size-3 shrink-0 text-muted-foreground opacity-35",
              "transition-[opacity,translate,color] duration-fast ease-out-strong",
              "group-hover/scrub:opacity-100",
              scrubNudge,
              "group-data-scrubbing/scrub:text-foreground group-data-scrubbing/scrub:opacity-100",
            )}
          />
          <label
            htmlFor={id}
            className={cn(
              "text-control text-muted-foreground transition-colors duration-fast",
              scrubCursor,
              "group-hover/scrub:text-foreground group-data-scrubbing/scrub:text-foreground",
            )}
          >
            {label}
          </label>
          {/* Base UI hides this in Safari, where the Pointer Lock
              notification causes a layout shift. */}
          <NumberField.ScrubAreaCursor data-slot="scrub-field-cursor">
            <ScrubIcon
              aria-hidden
              className="size-4 text-foreground drop-shadow-[0_1px_1px_var(--background)]"
            />
          </NumberField.ScrubAreaCursor>
        </NumberField.ScrubArea>

        <NumberField.Input
          data-slot="scrub-field-input"
          className={cn(
            "min-w-0 flex-1 bg-transparent py-1 pr-1 pl-1 text-right text-caption tabular-nums text-foreground outline-none",
            "read-only:cursor-default disabled:cursor-not-allowed",
            inputClassName,
          )}
        />
        {suffix ? (
          <span
            aria-hidden
            className="self-center pr-2 text-caption tabular-nums text-muted-foreground select-none"
          >
            {suffix}
          </span>
        ) : null}
      </NumberField.Group>
    </NumberField.Root>
  );
}

export { ScrubField };
export type { ScrubFieldProps };
scrub-field.tsx

API Reference

ScrubField
label

ReactNode

The drag handle, and the input's label. Pressing it and moving sideways scrubs the value.

value / defaultValue

number | null

Controlled and uncontrolled value. Pass `value` with `onValueChange`.

onValueChange

(value: number | null, details) => void

Fires on every change. `details.reason` is `'scrub'` for drags, `'keyboard'` for arrows, `'input-change'` for typing.

onValueCommitted

(value: number | null, details) => void

Fires once the pointer is released or the input is blurred.

min / max

number

Bounds. Step-based interaction always clamps to them.

step1

number | 'any'

Amount each pixel threshold, arrow key, or stepper press moves the value.

smallStep / largeStep0.1 / 10

number

Step used while Alt (fine) or Shift (coarse) is held.

format

Intl.NumberFormatOptions

Formats the displayed value. Use for percent, currency, and unit styles.

suffix

string

Static unit rendered inside the field, for units Intl has no format for such as `px` or `ms`.

pixelSensitivity2

number

Pixels the pointer must travel before the value moves. Raise it for small ranges.

direction"horizontal"

"horizontal" | "vertical"

Axis the scrub follows. `"vertical"` pulls up to increase. The handle's cursor and arrow both turn to match.

snapOnStepfalse

boolean

Snap to the nearest multiple of `step` while scrubbing or stepping.

allowWheelScrubfalse

boolean

Let the mouse wheel change the value while the input is focused.

disabled / readOnlyfalse

boolean

`disabled` blocks all interaction; `readOnly` keeps focus but blocks edits.

className / inputClassName

string

`className` extends the bordered control itself, `inputClassName` the number input inside it.