Forms

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.

forminputnumberstepperinteractive

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.

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

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

"use client"

import { MinusIcon, PlusIcon } from "lucide-react"
import type { InputProps, NumberFieldProps } from "react-aria-components"
import {
  Button,
  Group,
  Input as InputPrimitive,
  NumberField as NumberFieldPrimitive,
} from "react-aria-components"
import { cn } from "@/lib/utils"

/**
 * 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`).
 */
function NumberField({ className, ...props }: NumberFieldProps) {
  return (
    <NumberFieldPrimitive
      {...props}
      data-slot="control"
      className={cn(
        "group/number-field w-full",
        // label → control → hint stack with 6px between siblings.
        "[&>[data-slot=label]+[data-slot=control]]:mt-1.5",
        "[&>[data-slot=label]+[slot='description']]:mt-1",
        "[&>[slot=description]+[data-slot=control]]:mt-1.5",
        "[&>[data-slot=control]+[slot=description]]:mt-1.5",
        "[&>[data-slot=control]+[slot=errorMessage]]:mt-1.5",
        "in-disabled:opacity-50 disabled:opacity-50",
        className,
      )}
    />
  )
}

interface NumberInputProps extends Omit<InputProps, "prefix"> {
  /** 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. */
  hideStepper?: boolean
}

const addonStyles = cn(
  "inline-flex select-none items-center px-3 text-[13px] font-medium",
  "pointer-events-none bg-white/[0.02] text-quebi-fg-muted",
  "border border-cyan-500/20",
  "transition-[border-color] duration-200",
  "group-hover/addons:border-cyan-500/40 group-focus-within/addons:border-quebi-brand",
)

const stepperStyles = cn(
  "inline-flex items-center justify-center px-2.5",
  "bg-white/[0.02] text-quebi-fg-muted",
  "border border-cyan-500/20",
  "transition-[color,border-color,background-color] duration-200",
  "outline-none cursor-pointer",
  "hover:border-quebi-brand hover:text-quebi-brand",
  "focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-cyan-500/20 disabled:hover:text-quebi-fg-muted",
)

/**
 * NumberInput renders the input for a NumberField, with optional prefix/suffix
 * addons and increment / decrement steppers that read as one unified control
 * (shared focus ring, shared hover).
 */
function NumberInput({
  prefix,
  suffix,
  hideStepper,
  className,
  ...props
}: NumberInputProps) {
  return (
    <Group
      data-slot="control"
      className={cn(
        // Group named `addons` so the addons / steppers react to the wrapper's
        // hover and focus-within state.
        "group/addons flex w-full items-stretch rounded-quebi-sm",
        "transition-[box-shadow] duration-200",
        // Wrapper owns the outer focus ring so every segment highlights together.
        "focus-within:ring-2 focus-within:ring-quebi-brand/50",
        // Strip inner input's own ring (wrapper owns it).
        "[&_input:focus]:ring-0 [&_input:focus]:ring-transparent",
      )}
    >
      {prefix ? (
        <span data-slot="addon" className={cn(addonStyles, "rounded-s-quebi-sm border-r-0")}>
          {prefix}
        </span>
      ) : null}
      <InputPrimitive
        className={cn(
          "relative block w-full min-w-0 appearance-none text-sm text-white tabular-nums",
          "placeholder:text-quebi-fg-subtle",
          "border border-cyan-500/20 bg-white/[0.02] px-3 py-2.5",
          "transition-[border-color,box-shadow] duration-200",
          "enabled:hover:border-cyan-500/40",
          "outline-none focus:outline-none focus:border-quebi-brand",
          "invalid:border-red-500",
          "disabled:cursor-not-allowed disabled:opacity-50 in-disabled:opacity-50",
          "scheme-dark",
          // Corner rounding depends on neighbouring segments.
          prefix ? "rounded-s-none" : "rounded-s-quebi-sm",
          suffix || !hideStepper ? "rounded-e-none" : "rounded-e-quebi-sm",
          className,
        )}
        {...props}
      />
      {suffix ? (
        <span
          data-slot="addon"
          className={cn(
            addonStyles,
            "border-l-0",
            hideStepper ? "rounded-e-quebi-sm" : "",
          )}
        >
          {suffix}
        </span>
      ) : null}
      {!hideStepper ? (
        <div className="flex items-stretch">
          <Button
            slot="decrement"
            aria-label="Decrease"
            className={cn(stepperStyles, "-ml-px")}
          >
            <MinusIcon className="size-4" />
          </Button>
          <Button
            slot="increment"
            aria-label="Increase"
            className={cn(stepperStyles, "-ml-px rounded-e-quebi-sm")}
          >
            <PlusIcon className="size-4" />
          </Button>
        </div>
      ) : null}
    </Group>
  )
}

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