# Number Field

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

```tsx
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

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex lucide-react
   ```

2. Follow [Installation](https://carbon.style/docs/installation), including `states.ts`, `state-styles.ts`, `field-state.ts`, and `interop.css` for icons. Install [Input](https://carbon.style/docs/components/input) and [Input Group](https://carbon.style/docs/components/input-group) for the frame and stepper buttons. Use [Field](https://carbon.style/docs/components/field) for labels and validation.

3. Copy and paste the following code into your project.

   ```tsx title="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,
   }
   ```

4. Update the import paths to match your project setup.

## Usage

```tsx
import {
  NumberField,
  NumberFieldDecrement,
  NumberFieldGroup,
  NumberFieldIncrement,
  NumberFieldInput,
} from "@/components/ui/number-field"
```

```tsx
<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.

```tsx
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.

```tsx
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`.

```tsx
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.

```tsx
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](https://carbon.style/docs/components/form) to show native validation errors for empty or out-of-range values.

```tsx
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](https://base-ui.com/react/components/number-field), including `render`. Styled parts accept `xstyle`, `className`, and inline `style`.

### NumberFieldIncrement and NumberFieldDecrement

| Prop         | Type                                                                          | Default                      |
| ------------ | ----------------------------------------------------------------------------- | ---------------------------- |
| `variant`    | `"default" \| "outline" \| "secondary" \| "ghost" \| "destructive" \| "link"` | `"ghost"`                    |
| `size`       | `"xs" \| "sm" \| "icon-xs" \| "icon-sm"`                                      | `"icon-xs"`                  |
| `children`   | `ReactNode`                                                                   | Plus or minus icon           |
| `aria-label` | `string`                                                                      | `"Increase"` or `"Decrease"` |

### NumberFieldScrubAreaCursor

| Prop       | Type        | Default                |
| ---------- | ----------- | ---------------------- |
| `children` | `ReactNode` | Horizontal arrows icon |

### NumberFieldToolbarInput

Use this input inside a [Toolbar](https://carbon.style/docs/components/toolbar) to join its arrow navigation. It accepts `NumberFieldInput` props plus `focusableWhenDisabled` (default `true`).

### numberFieldStyles

| Key                                        | What it is                                  |
| ------------------------------------------ | ------------------------------------------- |
| `root`                                     | Label and control layout                    |
| `group`                                    | InputGroup padding and no wrapping          |
| `input`                                    | Centered tabular numbers                    |
| `inputBase`, `inputInGroup`                | Shared input styles                         |
| `undim`                                    | Clears dimming already applied by the group |
| `scrubArea`, `verticalScrub`, `inertScrub` | Drag area cursor states                     |
| `cursor`                                   | Custom scrub cursor icon size               |

### Data attributes

| Attribute                                                                                 | On                    | Effect             |
| ----------------------------------------------------------------------------------------- | --------------------- | ------------------ |
| `data-slot="number-field"`                                                                | root                  | Field anatomy      |
| `data-slot="number-field-group"`                                                          | group                 | Shared frame       |
| `data-slot="number-field-input"`                                                          | input outside a group | Input anatomy      |
| `data-slot="input-group-control"`                                                         | input inside a group  | Shared focus ring  |
| `data-slot="number-field-increment"`                                                      | increment             | Stepper anatomy    |
| `data-slot="number-field-decrement"`                                                      | decrement             | Stepper anatomy    |
| `data-slot="number-field-scrub-area"`                                                     | scrub area            | Drag target        |
| `data-slot="number-field-scrub-area-cursor"`                                              | scrub cursor          | Cursor anatomy     |
| `data-disabled`, `data-readonly`, `data-required`, `data-scrubbing`                       | parts                 | Number field state |
| `data-valid`, `data-invalid`, `data-touched`, `data-dirty`, `data-filled`, `data-focused` | parts                 | Field state        |
