component — inputs

Number Field

Accessible numeric input built on react-aria-components, styled with the quebi design system. Parses, steps, and formats numbers with increment/decrement steppers and optional prefix/suffix addons.

  • form
  • input
  • number
  • stepper
  • interactive

08 examples

Default

Label, control with steppers, and a hint.

How many seats to add.

Prefix & suffix

Addons attached to the edges read as one unified control.

£
VAT inclusive.
GB
Enter 0 for unlimited.

Sizes

xs, sm, and the default md — the same scale Input publishes, so xs (30px) and sm (38px) match Button's xs and sm. Narrow fields want hideStepper: the pair costs ~74px before a digit is drawn.

Stepper icons

plus-minus is the default and reads as an amount; chevron and arrow draw the up / down pair a spinner conventionally uses, for a number that is a position on a scale. Icons only — all three are the same row, so the control is the same size either way.

plus-minus — the default.
chevron.
arrow.

Without steppers

Hide the increment / decrement buttons for free-form entry.

Invalid

Validation styling with an error message.

%
Must be 100 or less.

Disabled

Controlled

source

number-field.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  ArrowDown as ArrowDownIcon,
  ArrowUp as ArrowUpIcon,
  ChevronDown as ChevronDownIcon,
  ChevronUp as ChevronUpIcon,
  MinusIcon,
  PlusIcon,
} from "lucide-react"
import type { InputProps, NumberFieldProps } from "react-aria-components"
import {
  Button,
  composeRenderProps,
  Group,
  Input as InputPrimitive,
  NumberField as NumberFieldPrimitive,
} from "react-aria-components"
import { useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"
import { fieldStyles } from "@/components/field"

/**
 * NumberField — quebi design system
 *
 * Built on react-aria-components. Wraps the label → control → hint stack and
 * gives the control number-aware behaviour (parsing, stepping, formatting).
 * Pair with the field primitives (`Label`, `Description`, `FieldError`).
 *
 * A focused field does *not* step on the wheel: `isWheelDisabled` defaults to
 * `true` here, inverting react-aria's default. The steppers, the arrow keys
 * and typing are unaffected.
 */
function NumberField({
  // react-aria turns a wheel tick over a *focused* field into a value step,
  // and `preventDefault`s the scroll, so the page does not move either: an
  // ordinary read-the-rest-of-the-form scroll silently rewrites the number
  // with no press, no keystroke and no undo (task #156). Wheel stepping is a
  // real desktop affordance, so this is a default and not a removal — pass
  // `isWheelDisabled={false}` to ask for it back, on a field the pointer is
  // over on purpose.
  isWheelDisabled = true,
  className,
  ...props
}: NumberFieldProps) {
  return (
    <NumberFieldPrimitive
      {...props}
      isWheelDisabled={isWheelDisabled}
      data-slot="control"
      className={composeRenderProps(className, (resolved) =>
        // The label → control → hint stack, from the one place that owns it:
        // this file used to carry a verbatim copy of `Field`'s five selectors,
        // which is how the two drifted apart in the first place.
        cn("group/number-field", fieldStyles, resolved),
      )}
    />
  )
}

/**
 * The field size scale — the same three steps `Input` publishes, so a number
 * field and the button beside it are the same height: `xs` is 30px and `sm`
 * 38px, matching `Button`'s `xs` and `sm`; `md` is the default.
 *
 * The frame belongs to the group, not the input, so it runs round the addons
 * and steppers too; the group's 1px border top (transparent when the field
 * is an underline) and bottom is what the 1px per side in that arithmetic is.
 * The addons and steppers stretch to the input, so sizing the input sizes the
 * whole group.
 */
const numberInputSizeStyles = {
  xs: "text-xs px-(--q-field-px) py-1.5",
  sm: "text-sm px-(--q-field-px) py-2",
  md: "text-sm px-(--q-field-px) py-2.5",
} as const

type NumberInputSize = keyof typeof numberInputSizeStyles

/**
 * The glyphs the stepper pair draws: decrement first, increment second.
 *
 * Only the icons change — the pair stays the side-by-side row it has always
 * been, so every variant is the same height and the same ~74px wide, and a
 * field that fits one fits all three.
 */
const numberInputStepperIcons = {
  "plus-minus": [MinusIcon, PlusIcon],
  chevron: [ChevronDownIcon, ChevronUpIcon],
  arrow: [ArrowDownIcon, ArrowUpIcon],
} as const

type NumberInputStepper = keyof typeof numberInputStepperIcons

interface NumberInputProps extends Omit<InputProps, "prefix" | "size"> {
  /** Text / glyph rendered in a tag attached to the left edge (e.g. `£`). */
  prefix?: React.ReactNode
  /** Text / glyph rendered in a tag attached to the right edge (e.g. `GB`). */
  suffix?: React.ReactNode
  /**
   * Hide the increment / decrement stepper buttons.
   *
   * Worth reaching for whenever the field is narrow: the pair costs ~74px of
   * a fixed width before a digit is drawn, and an input that is `w-full
   * min-w-0` inside a flex row will give that width up rather than overflow —
   * so a `w-24` field with steppers renders the buttons and no number.
   *
   * Left out, it is whatever the surrounding surface asked for — a table cell
   * hides them, because a grid is the narrow case and ↑ / ↓ still step. See
   * `@/lib/field-size`.
   */
  hideStepper?: boolean
  /**
   * Control height. Matches `Input`'s scale and `Button`'s `xs` / `sm`, and is
   * likewise taken from the surrounding surface when it is left out.
   */
  size?: NumberInputSize
  /**
   * Which glyphs the steppers draw. `plus-minus` is the default — the pair a
   * quantity reads best as, because +/- says "add one" without implying an
   * ordering. `chevron` and `arrow` draw the up / down pair a spinner
   * conventionally uses, which is the better read when the number is a
   * position on a scale (a page, a rank, a priority) rather than an amount.
   *
   * Geometry is not part of this choice: all three are the same horizontal
   * row, so swapping the glyph cannot change the control's size. See
   * `hideStepper` for the width that costs.
   */
  stepper?: NumberInputStepper
}

const addonStyles = cn(
  "inline-flex select-none items-center text-quebi-fg-subtle",
  "pointer-events-none",
)

const stepperStyles = cn(
  "inline-flex items-center justify-center px-1.5",
  "bg-transparent text-quebi-fg-subtle",
  "transition-colors duration-150",
  "outline-none cursor-pointer",
  "hover:text-quebi-fg",
  "focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset",
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-quebi-fg-subtle",
)

/**
 * NumberInput renders the input for a NumberField, with optional prefix/suffix
 * addons and increment / decrement steppers that read as one control: one
 * frame round all of them (an underline inside `quebi-editorial`), doubled
 * while anything inside has focus.
 */
function NumberInput({
  prefix,
  suffix,
  hideStepper: hideStepperProp,
  size: sizeProp,
  stepper = "plus-minus",
  className,
  ...props
}: NumberInputProps) {
  const { size, hideStepper } = useFieldSizing({ size: sizeProp, hideStepper: hideStepperProp })
  const [DecrementIcon, IncrementIcon] = numberInputStepperIcons[stepper]
  return (
    <Group
      data-slot="control"
      className={cn(
        "group/addons flex w-full items-stretch",
        "quebi-field",
        "transition-[border-color,box-shadow] duration-150",
        "focus-within:shadow-(--q-field-focus)",
        "group-invalid/number-field:[--q-field-edge:var(--q-danger)] group-invalid/number-field:focus-within:shadow-(--q-field-focus-danger)",
      )}
    >
      {prefix ? (
        <span data-slot="addon" className={cn(addonStyles, "ps-(--q-field-px) pe-2")}>
          {prefix}
        </span>
      ) : null}
      <InputPrimitive
        className={composeRenderProps(className, (resolved) =>
          cn(
            "relative block w-full min-w-0 appearance-none bg-transparent text-quebi-fg tabular-nums",
            "placeholder:text-quebi-fg-subtle",
            numberInputSizeStyles[size],
            "outline-none focus:outline-none",
            "disabled:cursor-not-allowed disabled:opacity-50 in-disabled:opacity-50",
            resolved,
          ),
        )}
        {...props}
      />
      {suffix ? (
        <span
          data-slot="addon"
          className={cn(addonStyles, "ps-2", hideStepper && "pe-(--q-field-px)")}
        >
          {suffix}
        </span>
      ) : null}
      {!hideStepper ? (
        <div className="flex items-stretch">
          <Button
            slot="decrement"
            aria-label="Decrease"
            className={stepperStyles}
          >
            <DecrementIcon className="size-4" />
          </Button>
          <Button
            slot="increment"
            aria-label="Increase"
            // The last edge is the field's: its inline padding (0 on an underline).
            className={cn(stepperStyles, "pe-(--q-field-px)")}
          >
            <IncrementIcon className="size-4" />
          </Button>
        </div>
      ) : null}
    </Group>
  )
}

export type { NumberFieldProps, NumberInputProps, NumberInputSize, NumberInputStepper }
export { NumberField, NumberInput }