Skip to content

Steppers you can hold, a label you can drag, digits that roll into place.

Text inputs@base-ui/react@number-flow/react

01Preview

orders-db

Postgres 16 · eu-west-2

We pause scaling when the month's bill reaches this

Estimated$216.00 / month

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react @number-flow/react

03Usage

import { NumberField } from "@/components/ui/number-field";

<NumberField label="Replicas" defaultValue={3} min={1} max={8} />

<NumberField
  label="Monthly spend cap"
  defaultValue={1200}
  step={50}
  format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
/>

04Source

"use client";
import { NumberField as Base } from "@base-ui/react/number-field";
import NumberFlow, { type Format } from "@number-flow/react";
import { useId, useState } from "react";
import { cn } from "@/lib/cn";
import { Minus, Plus } from "@/lib/icons";
import { ease } from "@/lib/motion";

type BaseRootProps = React.ComponentProps<typeof Base.Root>;

export type NumberFieldProps = Omit<BaseRootProps, "children" | "className" | "render"> & {
  /** Visible label. Drag it sideways to scrub the value. */
  label: React.ReactNode;
  /** A short unit after the number, like "GB" or "seats". For currency or percent, use `format`. */
  unit?: string;
  /** A hint under the field. */
  description?: React.ReactNode;
  /** Let the label scrub the value when dragged. */
  scrub?: boolean;
  /** Pixels of drag per step while scrubbing. */
  scrubSensitivity?: number;
  size?: "sm" | "md" | "lg";
  placeholder?: string;
  className?: string;
};

const sizes = {
  sm: { group: "h-7 rounded-md", pad: "pl-2", btn: "w-7", text: "text-base sm:text-[12.5px]", icon: 12 },
  md: { group: "h-8 rounded-lg", pad: "pl-2.5", btn: "w-8", text: "text-base sm:text-[13px]", icon: 14 },
  lg: { group: "h-9 rounded-lg", pad: "pl-3", btn: "w-9", text: "text-base sm:text-[13px]", icon: 14 },
};

// Digits spin on the expo ease-out, quicker than the default so a held stepper or a fast scrub stays legible.
const roll = { duration: 420, easing: `cubic-bezier(${ease.out.join(",")})` };

type Reason = Parameters<NonNullable<BaseRootProps["onValueChange"]>>[1]["reason"];
const typedReasons: Reason[] = ["input-change", "input-clear", "input-paste"];

export function NumberField({
  label,
  unit,
  description,
  scrub = true,
  scrubSensitivity = 4,
  size = "md",
  placeholder,
  className,
  id: idProp,
  value: valueProp,
  defaultValue,
  onValueChange,
  format,
  locale,
  ...rest
}: NumberFieldProps) {
  const autoId = useId();
  const id = idProp ?? autoId;
  const descId = `${id}-description`;
  // Mirror the value so the rolling display can follow it whether or not the field is controlled.
  const [inner, setInner] = useState<number | null>(defaultValue ?? null);
  const value = valueProp !== undefined ? valueProp : inner;
  // While someone types, show the real text; any step, scrub or blur hands back to the rolling digits.
  const [typing, setTyping] = useState(false);
  const s = sizes[size];
  const rolling = !typing && value != null;

  return (
    <Base.Root
      id={id}
      value={valueProp}
      defaultValue={defaultValue}
      format={format}
      locale={locale}
      onValueChange={(next, details) => {
        setInner(next);
        setTyping(typedReasons.includes(details.reason));
        onValueChange?.(next, details);
      }}
      className={cn("group/number flex min-w-0 flex-col gap-1.5", className)}
      {...rest}
    >
      {scrub ? (
        <Base.ScrubArea
          direction="horizontal"
          pixelSensitivity={scrubSensitivity}
          className="group/scrub flex w-fit cursor-ew-resize touch-none select-none items-center gap-1 data-disabled:cursor-default data-readonly:cursor-default"
        >
          <Label id={id}>{label}</Label>
          {/* A quiet ↔ that surfaces on hover, so the scrub is discoverable without a tooltip. */}
          <svg
            aria-hidden
            width="12"
            height="12"
            viewBox="0 0 16 16"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.4"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="-translate-x-0.5 text-fg-4 opacity-0 transition-[opacity,translate] duration-150 ease-out group-hover/scrub:translate-x-0 group-hover/scrub:opacity-100 group-data-disabled/scrub:hidden group-data-readonly/scrub:hidden group-data-scrubbing/scrub:translate-x-0 group-data-scrubbing/scrub:text-fg-2 group-data-scrubbing/scrub:opacity-100 pointer-coarse:hidden"
          >
            <path d="M5.5 5 2.5 8l3 3M10.5 5l3 3-3 3M3 8h10" />
          </svg>
          <Base.ScrubAreaCursor className="drop-shadow-[0_1px_1px_var(--overlay)]">
            <svg width="24" height="14" viewBox="0 0 24 14" aria-hidden className="block">
              <path d="M18.5 4.8H5.5V1.5L1 7l4.5 5.5V9.2h13v3.3L23 7l-4.5-5.5z" fill="var(--fg)" stroke="var(--frame)" strokeWidth="1" strokeLinejoin="round" />
            </svg>
          </Base.ScrubAreaCursor>
        </Base.ScrubArea>
      ) : (
        <Label id={id}>{label}</Label>
      )}

      <Base.Group
        className={cn(
          "group/box relative flex w-full items-stretch overflow-hidden border border-line-2 bg-raised text-fg shadow-[var(--shadow)]",
          "transition-[border-color,box-shadow,background-color] duration-150 ease-out",
          "hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 hover:focus-within:border-fg-3",
          "data-scrubbing:border-fg-3 data-scrubbing:ring-3 data-scrubbing:ring-fg/8",
          "data-invalid:border-danger/70 data-invalid:focus-within:border-danger data-invalid:focus-within:ring-danger/15",
          "data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:shadow-none data-disabled:hover:border-line-2",
          "data-readonly:bg-frame data-readonly:shadow-none data-readonly:hover:border-line-2",
          s.group,
        )}
      >
        <div className={cn("relative flex min-w-0 flex-1 items-center", s.pad)}>
          <Base.Input
            placeholder={placeholder}
            aria-describedby={description != null ? descId : undefined}
            onBlur={() => setTyping(false)}
            className={cn(
              "h-full w-full min-w-0 bg-transparent tabular text-fg caret-fg outline-none placeholder:text-fg-4 disabled:cursor-not-allowed",
              s.text,
              // The text stays in place for the caret and selection; the digits drawn over it roll.
              rolling && "text-transparent selection:bg-fg/20",
            )}
          />
          {rolling && (
            <span aria-hidden className={cn("pointer-events-none absolute inset-y-0 left-0 flex items-center whitespace-nowrap tabular text-fg", s.text, s.pad)}>
              <NumberFlow
                value={value}
                locales={locale}
                format={format as Format | undefined}
                spinTiming={roll}
                transformTiming={roll}
              />
            </span>
          )}
        </div>

        {unit && (
          <span aria-hidden className={cn("flex shrink-0 select-none items-center pl-1.5 pr-2.5 text-fg-3", s.text)}>
            {unit}
          </span>
        )}

        <Stepper kind="decrement" size={size} />
        <Stepper kind="increment" size={size} />
      </Base.Group>

      {description != null && (
        <p id={descId} className="text-pretty text-[12px] leading-4 text-fg-3">
          {description}
        </p>
      )}
    </Base.Root>
  );
}

function Label({ id, children }: { id: string; children: React.ReactNode }) {
  return (
    <label
      htmlFor={id}
      className="cursor-[inherit] text-[12.5px] font-medium text-fg-2 transition-colors duration-150 group-hover/scrub:text-fg group-data-scrubbing/scrub:text-fg group-data-disabled/number:text-fg-3"
    >
      {children}
    </label>
  );
}

function Stepper({ kind, size }: { kind: "increment" | "decrement"; size: NonNullable<NumberFieldProps["size"]> }) {
  const Part = kind === "increment" ? Base.Increment : Base.Decrement;
  const s = sizes[size];
  const Icon = kind === "increment" ? Plus : Minus;
  return (
    <Part
      aria-label={kind === "increment" ? "Increase" : "Decrease"}
      className={cn(
        "group/step relative grid shrink-0 place-items-center border-l border-line text-fg-3 outline-none",
        "transition-[background-color,color] duration-150 ease-out hover:bg-hover hover:text-fg active:bg-hover",
        "focus-visible:bg-hover focus-visible:text-fg",
        "data-disabled:pointer-events-none data-disabled:text-fg-4 data-disabled:opacity-60",
        // Touch gets a 44px target without the button drawing any bigger.
        "before:absolute before:inset-x-0 before:-inset-y-1.5 before:content-[''] pointer-coarse:before:-inset-y-2",
        s.btn,
      )}
    >
      <Icon
        size={s.icon}
        className="transition-transform duration-150 ease-out group-active/step:scale-[0.8] group-active/step:duration-75 motion-reduce:transition-none"
      />
    </Part>
  );
}

05Props

PropTypeDefaultDescription
label*ReactNodeVisible label. Dragging it sideways scrubs the value.
valuenumber | nullThe value, when controlled. Pair with onValueChange.
defaultValuenumberThe starting value, when uncontrolled.
onValueChange(value: number | null, details) => voidCalled on every change; details.reason says whether it came from typing, a stepper, the keyboard, the wheel or a scrub.
onValueCommitted(value: number | null, details) => voidCalled when a change settles: on blur, on releasing a stepper or ending a scrub.
minnumberLowest value. The minus stepper disables when it's reached.
maxnumberHighest value. The plus stepper disables when it's reached.
stepnumber1Amount per stepper press, arrow key and scrub step. Shift uses largeStep, Alt uses smallStep.
largeStepnumber10Step with Shift held.
unitstringA short unit after the number, like "GB" or "seats". Use format for currency and percent.
formatIntl.NumberFormatOptionsFormatting for both the input and the rolling digits.
localeIntl.LocalesArgumentLocale for formatting and parsing. Set it when you server-render, so server and browser agree.
descriptionReactNodeA hint under the field, linked to the input.
scrubbooleantrueLet the label scrub the value when dragged.
scrubSensitivitynumber4Pixels of drag per step.
size"sm" | "md" | "lg""md"28, 32 or 36px tall; the steppers stay square.
allowWheelScrubbooleanfalseLet the mouse wheel change the value while the field is focused and hovered.
disabledbooleanfalseDims the field and ignores input.
readOnlybooleanfalseShows the value flat, steppers unavailable, still focusable and selectable.

06Notes

Behavior

  • Holding a stepper steps once, waits 400ms, then repeats every 60ms; releasing commits once, not per step.
  • The rolling digits draw over the real input, which keeps its caret and selection underneath. Typing hands the display back to the plain text; the next step, scrub or blur rolls again.
  • Values typed past min or max are clamped when the field loses focus, and the digits roll to the clamped number so the correction is visible.
  • The steppers disable at the bounds rather than silently doing nothing, and they're 44px tall on touch through an invisible extension.

Motion

  • Digits spin for 420ms on the expo ease-out, faster than the default so a held stepper or a quick scrub stays legible; they roll up when the value rises and down when it falls.
  • The stepper icon squeezes to 0.8 for 75ms while pressed; the field's border and 3px halo come up while scrubbing, so the drag has a visible target.
  • A small ↔ slides 2px in beside the label on hover to show it can be dragged. Reduced motion shows the digits without spinning and drops the icon squeeze.

Accessibility

  • Base UI NumberField: the input has the number field role description, ↑/↓ step, Shift and Alt change the step, Home and End jump to min and max.
  • The label is a real label for the input; the rolling digits are aria-hidden, so screen readers read the input's own formatted value once.
  • Steppers are labeled Increase and Decrease and expose disabled at the bounds.