Number Field

A numeric input with increment and decrement buttons and a draggable label.

import * as stylex from "@stylexjs/stylex"
import {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
  NumberFieldScrubArea,
  NumberFieldScrubAreaCursor,
} from "@/components/ui/number-field"
import { Field, FieldLabel } from "@/components/ui/field"

const styles = stylex.create({ field: { width: "12rem" } })
export default function NumberFieldDemo() {
  return (
    <Field xstyle={styles.field}>
      <NumberField defaultValue={100}>
        <NumberFieldScrubArea>
          <FieldLabel>Amount</FieldLabel>
          <NumberFieldScrubAreaCursor />
        </NumberFieldScrubArea>
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
    </Field>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex lucide-react

Follow Installation, including states.ts, state-styles.ts, field-state.ts, and interop.css for icons. Install Input and Input Group for the frame and stepper buttons. Use Field for labels and validation.

Copy and paste the following code into your project.

components/ui/number-field.tsx
"use client"

import { use, type CSSProperties, type ComponentProps } from "react"
import { Toolbar as ToolbarPrimitive } from "@base-ui/react/toolbar"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { ArrowLeftRight, Minus, Plus } from "lucide-react"

import { InputGroupContext, inputStyles } from "@/components/ui/input"
import { InputGroup, InputGroupButton } from "@/components/ui/input-group"
import { FieldStateContext } from "@/lib/field-state"
import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: { display: "flex", flexDirection: "column", gap: space.s2 },
  group: { flexWrap: "nowrap", paddingInline: space.s1 },
  input: { textAlign: "center", fontVariantNumeric: "tabular-nums" },
  undim: { opacity: 1 },
  scrubArea: {
    display: "inline-flex",
    width: "fit-content",
    cursor: "ew-resize",
    userSelect: "none",
  },
  verticalScrub: { cursor: "ns-resize" },
  inertScrub: { cursor: "default" },
  cursor: { "--carbon-icon-size": space.s5 },
})
const numberFieldStyles = {
  ...styles,
  inputBase: inputStyles.base,
  inputInGroup: inputStyles.inGroup,
}
type Overrides = { xstyle?: StyleXStyles; className?: string; style?: CSSProperties }

function NumberField({
  xstyle,
  className,
  style,
  ...props
}: Omit<NumberFieldPrimitive.Root.Props, "className" | "style"> & Overrides) {
  return (
    <NumberFieldPrimitive.Root
      data-slot="number-field"
      {...props}
      {...stateStyles(() => [styles.root, xstyle], { className, style })}
    />
  )
}

function NumberFieldGroup({
  render,
  xstyle,
  ...props
}: Omit<NumberFieldPrimitive.Group.Props, "className" | "style"> & Overrides) {
  return (
    <NumberFieldPrimitive.Group
      data-slot="number-field-group"
      {...props}
      render={(groupProps, state) => (
        <InputGroup
          {...groupProps}
          disabled={state.disabled}
          invalid={state.valid === false}
          xstyle={[styles.group, xstyle]}
          render={typeof render === "function" ? (props) => render(props, state) : render}
        />
      )}
    />
  )
}

function NumberFieldInput({
  xstyle,
  className,
  style,
  ...props
}: Omit<NumberFieldPrimitive.Input.Props, "className" | "style"> & Overrides) {
  const group = use(InputGroupContext)
  const field = use(FieldStateContext)
  return (
    <NumberFieldPrimitive.Input
      data-slot={group ? "input-group-control" : "number-field-input"}
      {...props}
      {...stateStyles(
        (state) => [
          inputStyles.base,
          !group &&
            (state.valid === false ||
              field?.valid === false ||
              props["aria-invalid"] === true ||
              props["aria-invalid"] === "true") &&
            states.invalidRing,
          !group && state.disabled && states.disabled,
          group && inputStyles.inGroup,
          styles.input,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

type StepperProps = Omit<NumberFieldPrimitive.Increment.Props, "className" | "style"> &
  Overrides &
  Pick<ComponentProps<typeof InputGroupButton>, "variant" | "size">
function NumberFieldStepper({
  primitive: Primitive,
  render,
  children,
  xstyle,
  variant = "ghost",
  nativeButton = true,
  size = "icon-xs",
  ...props
}: StepperProps & { primitive: typeof NumberFieldPrimitive.Increment }) {
  const group = use(InputGroupContext)
  return (
    <Primitive
      {...props}
      nativeButton={nativeButton}
      render={(buttonProps, state) => (
        <InputGroupButton
          {...buttonProps}
          disabled={state.disabled || state.readOnly}
          focusableWhenDisabled
          nativeButton={nativeButton}
          variant={variant}
          size={size}
          xstyle={[group?.disabled && styles.undim, xstyle]}
          render={typeof render === "function" ? (props) => render(props, state) : render}
        >
          {children}
        </InputGroupButton>
      )}
    />
  )
}
function NumberFieldDecrement({ children, ...props }: StepperProps) {
  return (
    <NumberFieldStepper
      primitive={NumberFieldPrimitive.Decrement}
      data-slot="number-field-decrement"
      aria-label="Decrease"
      {...props}
    >
      {children ?? <Minus />}
    </NumberFieldStepper>
  )
}
function NumberFieldIncrement({ children, ...props }: StepperProps) {
  return (
    <NumberFieldStepper
      primitive={NumberFieldPrimitive.Increment}
      data-slot="number-field-increment"
      aria-label="Increase"
      {...props}
    >
      {children ?? <Plus />}
    </NumberFieldStepper>
  )
}
function NumberFieldToolbarInput({
  focusableWhenDisabled = true,
  ...props
}: ComponentProps<typeof NumberFieldInput> & { focusableWhenDisabled?: boolean }) {
  return (
    <ToolbarPrimitive.Input
      disabled={props.disabled}
      focusableWhenDisabled={focusableWhenDisabled}
      render={<NumberFieldInput {...props} />}
    />
  )
}

function NumberFieldScrubArea({
  direction = "horizontal",
  xstyle,
  className,
  style,
  ...props
}: Omit<NumberFieldPrimitive.ScrubArea.Props, "className" | "style"> & Overrides) {
  return (
    <NumberFieldPrimitive.ScrubArea
      data-slot="number-field-scrub-area"
      direction={direction}
      {...props}
      {...stateStyles(
        (state) => [
          styles.scrubArea,
          direction === "vertical" && styles.verticalScrub,
          (state.disabled || state.readOnly) && styles.inertScrub,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}
function NumberFieldScrubAreaCursor({
  children,
  xstyle,
  className,
  style,
  ...props
}: Omit<NumberFieldPrimitive.ScrubAreaCursor.Props, "className" | "style"> & Overrides) {
  return (
    <NumberFieldPrimitive.ScrubAreaCursor
      data-slot="number-field-scrub-area-cursor"
      {...props}
      {...stateStyles(() => [styles.cursor, xstyle], { className, style, hook: "carbon-icons" })}
    >
      {children ?? <ArrowLeftRight />}
    </NumberFieldPrimitive.ScrubAreaCursor>
  )
}

export {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldToolbarInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
  NumberFieldScrubArea,
  NumberFieldScrubAreaCursor,
  numberFieldStyles,
}

Update the import paths to match your project setup.

Usage

import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/ui/number-field"
<Field>
  <FieldLabel>Amount</FieldLabel>
  <NumberField defaultValue={100}>
    <NumberFieldGroup>
      <NumberFieldDecrement />
      <NumberFieldInput />
      <NumberFieldIncrement />
    </NumberFieldGroup>
  </NumberField>
</Field>

Put name, value, min, max, and step on NumberField. Use FieldLabel or aria-label to name the input.

Bounds and steps

Use min, max, and step to constrain adjustments.

Between 0 and 10, in steps of 2.

import * as stylex from "@stylexjs/stylex"
import {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
} from "@/components/ui/number-field"
import { Field, FieldLabel, FieldDescription } from "@/components/ui/field"

const styles = stylex.create({ field: { width: "12rem" } })
export default function NumberFieldBounds() {
  return (
    <Field xstyle={styles.field}>
      <FieldLabel>Quantity</FieldLabel>
      <NumberField min={0} max={10} step={2} defaultValue={2}>
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
      <FieldDescription>Between 0 and 10, in steps of 2.</FieldDescription>
    </Field>
  )
}

Formatting

Pass locale and format to display a formatted number.

import * as stylex from "@stylexjs/stylex"
import {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
} from "@/components/ui/number-field"
import { Field, FieldLabel } from "@/components/ui/field"

const styles = stylex.create({ field: { width: "12rem" } })
export default function NumberFieldFormat() {
  return (
    <Field xstyle={styles.field}>
      <FieldLabel>Price</FieldLabel>
      <NumberField
        defaultValue={19.99}
        min={0}
        step={0.01}
        locale="en-US"
        format={{ style: "currency", currency: "USD" }}
      >
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
    </Field>
  )
}

Controlled

Use value and onValueChange to control the number; an empty input produces null.

5 guests

import * as stylex from "@stylexjs/stylex"
import {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
} from "@/components/ui/number-field"
import { Field, FieldLabel, FieldDescription } from "@/components/ui/field"
import { useState } from "react"

const styles = stylex.create({ field: { width: "12rem" } })
export default function NumberFieldControlled() {
  const [value, setValue] = useState<number | null>(5)
  return (
    <Field xstyle={styles.field}>
      <FieldLabel>Guests</FieldLabel>
      <NumberField value={value} onValueChange={setValue} min={0}>
        <NumberFieldGroup>
          <NumberFieldDecrement />
          <NumberFieldInput />
          <NumberFieldIncrement />
        </NumberFieldGroup>
      </NumberField>
      <FieldDescription>{value === null ? "No value" : `${value} guests`}</FieldDescription>
    </Field>
  )
}

Disabled and invalid

Set disabled or invalid on Field, or use readOnly on NumberField to keep the value fixed.

import * as stylex from "@stylexjs/stylex"
import {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
} from "@/components/ui/number-field"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({ field: { width: "12rem" } })
export default function NumberFieldDisabled() {
  return (
    <VStack gap={4}>
      {(["disabled", "readOnly", "invalid", "disabled-invalid"] as const).map((state) => (
        <Field
          key={state}
          xstyle={styles.field}
          disabled={state.includes("disabled")}
          invalid={state.includes("invalid")}
        >
          <FieldLabel>{state}</FieldLabel>
          <NumberField defaultValue={10} readOnly={state === "readOnly"}>
            <NumberFieldGroup>
              <NumberFieldDecrement />
              <NumberFieldInput />
              <NumberFieldIncrement />
            </NumberFieldGroup>
          </NumberField>
          {state.includes("invalid") && <FieldError>Enter a different amount.</FieldError>}
        </Field>
      ))}
    </VStack>
  )
}

Validation

Use required and allowOutOfRange with Form to show native validation errors for empty or out-of-range values.

Enter between 1 and 10.

import * as stylex from "@stylexjs/stylex"
import {
  NumberField,
  NumberFieldGroup,
  NumberFieldInput,
  NumberFieldIncrement,
  NumberFieldDecrement,
} from "@/components/ui/number-field"
import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field"
import { useState } from "react"
import { Form } from "@/components/ui/form"
import { Button } from "@/components/ui/button"

const styles = stylex.create({ field: { width: "12rem" } })
export default function NumberFieldValidation() {
  const [submitted, setSubmitted] = useState(false)
  return (
    <Form onFormSubmit={() => setSubmitted(true)}>
      <Field name="quantity" xstyle={styles.field}>
        <FieldLabel>Order quantity</FieldLabel>
        <NumberField min={1} max={10} required allowOutOfRange>
          <NumberFieldGroup>
            <NumberFieldDecrement />
            <NumberFieldInput />
            <NumberFieldIncrement />
          </NumberFieldGroup>
        </NumberField>
        <FieldDescription>Enter between 1 and 10.</FieldDescription>
        <FieldError />
      </Field>
      <Button type="submit">Submit</Button>
      {submitted && <output>Order submitted.</output>}
    </Form>
  )
}

API reference

Parts accept Base UI Number Field props, including render. Styled parts accept xstyle, className, and inline style.

NumberFieldIncrement and NumberFieldDecrement

PropTypeDefault
variant"default" | "outline" | "secondary" | "ghost" | "destructive" | "link""ghost"
size"xs" | "sm" | "icon-xs" | "icon-sm""icon-xs"
childrenReactNodePlus or minus icon
aria-labelstring"Increase" or "Decrease"

NumberFieldScrubAreaCursor

PropTypeDefault
childrenReactNodeHorizontal arrows icon

NumberFieldToolbarInput

Use this input inside a Toolbar to join its arrow navigation. It accepts NumberFieldInput props plus focusableWhenDisabled (default true).

numberFieldStyles

KeyWhat it is
rootLabel and control layout
groupInputGroup padding and no wrapping
inputCentered tabular numbers
inputBase, inputInGroupShared input styles
undimClears dimming already applied by the group
scrubArea, verticalScrub, inertScrubDrag area cursor states
cursorCustom scrub cursor icon size

Data attributes

AttributeOnEffect
data-slot="number-field"rootField anatomy
data-slot="number-field-group"groupShared frame
data-slot="number-field-input"input outside a groupInput anatomy
data-slot="input-group-control"input inside a groupShared focus ring
data-slot="number-field-increment"incrementStepper anatomy
data-slot="number-field-decrement"decrementStepper anatomy
data-slot="number-field-scrub-area"scrub areaDrag target
data-slot="number-field-scrub-area-cursor"scrub cursorCursor anatomy
data-disabled, data-readonly, data-required, data-scrubbingpartsNumber field state
data-valid, data-invalid, data-touched, data-dirty, data-filled, data-focusedpartsField state