Propel

Number Field

A bounded numeric input with decrement and increment stepper buttons.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { NumberField, numberFieldStepperSize } from "@makeplane/propel/components/number-field";
import { Minus, Plus } from "lucide-react";
import * as React from "react";

export default function BasicDemo() {
  const [value, setValue] = React.useState<number | null>(2);

  return (
    <NumberField
      aria-label="Number of instances"
      size="lg"
      min={1}
      max={64}
      value={value}
      onValueChange={setValue}
      decrement={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Decrease"
          icon={<Icon icon={Minus} />}
        />
      }
      increment={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Increase"
          icon={<Icon icon={Plus} />}
        />
      }
    />
  );
}

Installation

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

Usage

import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { NumberField, numberFieldStepperSize } from "@makeplane/propel/components/number-field";
import { Minus, Plus } from "lucide-react";
import * as React from "react";

export default function BasicDemo() {
  const [value, setValue] = React.useState<number | null>(2);

  return (
    <NumberField
      aria-label="Number of instances"
      size="lg"
      min={1}
      max={64}
      value={value}
      onValueChange={setValue}
      decrement={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Decrease"
          icon={<Icon icon={Minus} />}
        />
      }
      increment={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Increase"
          icon={<Icon icon={Plus} />}
        />
      }
    />
  );
}

Examples

Sizes

The size axis sets the frame height (sm 24 / md 28 / lg 32). Pair each stepper IconButton with numberFieldStepperSize[size] — button size is one rung below the field — so the ghost controls sit flush inside the outer inset (1px border + 1px pad). No xs (20px) frame: grouped controls omit halo rungs.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import {
  NumberField,
  type NumberFieldSize,
  numberFieldStepperSize,
} from "@makeplane/propel/components/number-field";
import { Minus, Plus } from "lucide-react";

const SIZES: NumberFieldSize[] = ["sm", "md", "lg"];

export default function SizesDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      {SIZES.map((size) => (
        <NumberField
          key={size}
          aria-label={`Number of seats (${size})`}
          size={size}
          defaultValue={4}
          min={1}
          max={64}
          decrement={
            <IconButton
              variant="ghost"
              size={numberFieldStepperSize[size]}
              aria-label={`Decrease (${size})`}
              icon={<Icon icon={Minus} />}
            />
          }
          increment={
            <IconButton
              variant="ghost"
              size={numberFieldStepperSize[size]}
              aria-label={`Increase (${size})`}
              icon={<Icon icon={Plus} />}
            />
          }
        />
      ))}
    </div>
  );
}

Disabled

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { NumberField, numberFieldStepperSize } from "@makeplane/propel/components/number-field";
import { Minus, Plus } from "lucide-react";

export default function DisabledDemo() {
  return (
    <NumberField
      aria-label="Number of seats"
      size="lg"
      defaultValue={12}
      min={1}
      max={64}
      disabled
      decrement={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Decrease"
          icon={<Icon icon={Minus} />}
        />
      }
      increment={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Increase"
          icon={<Icon icon={Plus} />}
        />
      }
    />
  );
}

Invalid

NumberField has no baked-in error prop. Wrap it in Field invalid (and optional FieldError) so Base UI propagates data-invalid / aria-invalid to the input; the group border turns danger via :has([data-invalid]).

Enter a value between 1 and 64.
Show code
import { Field, FieldError, FieldLabel } from "@makeplane/propel/components/field";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { NumberField, numberFieldStepperSize } from "@makeplane/propel/components/number-field";
import { Minus, Plus } from "lucide-react";

export default function InvalidDemo() {
  return (
    <Field name="seats" invalid>
      <FieldLabel id="number-field-seats-label" size="lg" inset={false}>
        Number of seats
      </FieldLabel>
      <NumberField
        aria-labelledby="number-field-seats-label"
        size="lg"
        defaultValue={99}
        min={1}
        max={64}
        decrement={
          <IconButton
            variant="ghost"
            size={numberFieldStepperSize.lg}
            aria-label="Decrease"
            icon={<Icon icon={Minus} />}
          />
        }
        increment={
          <IconButton
            variant="ghost"
            size={numberFieldStepperSize.lg}
            aria-label="Increase"
            icon={<Icon icon={Plus} />}
          />
        }
      />
      <FieldError size="lg" match={true}>
        Enter a value between 1 and 64.
      </FieldError>
    </Field>
  );
}

Formatted

Pass format (an Intl.NumberFormatOptions) to format the displayed value — here as a currency amount stepped by 50.

Show code
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { NumberField, numberFieldStepperSize } from "@makeplane/propel/components/number-field";
import { Minus, Plus } from "lucide-react";
import * as React from "react";

export default function FormattedDemo() {
  const [value, setValue] = React.useState<number | null>(250);

  return (
    <NumberField
      aria-label="Monthly budget"
      size="lg"
      min={0}
      max={10000}
      step={50}
      value={value}
      onValueChange={setValue}
      format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
      decrement={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Decrease"
          icon={<Icon icon={Minus} />}
        />
      }
      increment={
        <IconButton
          variant="ghost"
          size={numberFieldStepperSize.lg}
          aria-label="Increase"
          icon={<Icon icon={Plus} />}
        />
      }
    />
  );
}

Scrub

NumberFieldScrubArea is not baked into the ready-made. Assemble Base UI NumberField.Root with propel’s scrub + group parts (see Storybook Components/NumberField → Scrub). Drag the scrub label horizontally to change the value:

import { NumberField as BaseNumberField } from "@base-ui/react/number-field";
import {
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldScrubArea,
  NumberFieldScrubAreaCursor,
  numberFieldStepperSize,
} from "@makeplane/propel/components/number-field";
import { NumberField as NumberFieldFrame } from "@makeplane/propel/elements/number-field";

<BaseNumberField.Root defaultValue={24} render={<NumberFieldFrame />}>
  <NumberFieldScrubArea>
    <span className="cursor-ew-resize">Amount</span>
    <NumberFieldScrubAreaCursor />
  </NumberFieldScrubArea>
  <BaseNumberField.Group render={<NumberFieldGroup size="lg" />}>
    {/* Decrement / Input / Increment grafts — same as the ready-made */}
  </BaseNumberField.Group>
</BaseNumberField.Root>;

API Reference

NumberField

The ready-made number field: a numeric input flanked by decrement / increment buttons. Drive it with `value`/`defaultValue` + `onValueChange`, bound it with `min`/`max`/`step`, and pass `format` (an `Intl.NumberFormatOptions`) to format the displayed value. Name the input with `aria-label` or `aria-labelledby` — the ready-made has no baked visible label. Grafts Base UI `NumberField` behavior onto the `elements/number-field` styled parts (`NumberField` root + `NumberFieldGroup` + `NumberFieldInput`) via `render`. The `decrement`/`increment` steppers are consumer-provided controls, grafted onto Base UI's `Decrement`/`Increment` behavior.

PropTypeDefaultDescription
size(required)"sm" | "md" | "lg"Visual size of the field frame (`sm` 24 / `md` 28 / `lg` 32). Pair steppers with {@link numberFieldStepperSize }`[size]` — button size is one rung below the field.
decrement(required)ReactElement<unknown, string | JSXElementConstructor<any>>The decrement control (e.g. an `IconButton`), rendered as the field's decrement stepper. It carries its own — localizable — `aria-label`; the field bakes no label or glyph.
increment(required)ReactElement<unknown, string | JSXElementConstructor<any>>The increment control (e.g. an `IconButton`), rendered as the field's increment stepper. It carries its own — localizable — `aria-label`; the field bakes no label or glyph.
aria-labelstringAccessible name for the value input. Required when `aria-labelledby` is omitted. Accessible name for the value input. Optional when `aria-labelledby` is set.
aria-labelledbystringId of a visible label element. Optional when `aria-label` is set. Id of a visible label element. Required when `aria-label` is omitted.

NumberFieldScrubArea

Drag-to-change scrubbing over the field's label region — Base UI behavior, no chrome.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, NumberFieldScrubAreaState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

NumberFieldScrubAreaCursor

The virtual cursor shown while scrubbing; pass your cursor svg as children.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, NumberFieldScrubAreaCursorState>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

NumberField

The styled number field frame — stacks the label, group, and messages. Base-UI-agnostic — graft the number field behavior in `components` via `<BaseNumberField.Root render={<NumberField/>} />`.

PropTypeDefaultDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

NumberFieldGroup

The styled bordered group wrapping the decrement / input / increment controls. Base-UI-agnostic — graft in `components` via `<BaseNumberField.Group render={<NumberFieldGroup/>} />`.

PropTypeDefaultDescription
size(required)"sm" | "md" | "lg"
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.

NumberFieldInput

The styled numeric input. Base-UI-agnostic — graft the number field behavior in `components` via `<BaseNumberField.Input render={<NumberFieldInput/>} />`.

PropTypeDefaultDescription
size(required)"sm" | "md" | "lg"
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render.