Skip to content

NumberField

Form

Labeled numeric entry with bounded increment and decrement controls.

Controlled seatsDecimal steppingBoundsDisabled allocation
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Use value/onValueChange for controlled values, including undefined for an empty field. For uncontrolled use, provide defaultValue. Typing can temporarily exceed bounds; blur and step buttons normalize the value. step must be positive and min should not exceed max.

Import
import { NumberField } from "@/components/ui/number-field";

Shipped as editable source in src/components/ui/number-field.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/number-field.tsx
"use client";
import { useId, useState } from "react";
import { Minus, Plus } from "lucide-react";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface NumberFieldProps {
  label: string;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number | undefined) => void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  required?: boolean;
  name?: string;
  className?: string;
}
export function NumberField(props: NumberFieldProps) {
  const {
    label,
    value,
    defaultValue,
    onValueChange,
    min,
    max,
    step = 1,
    disabled,
    required,
    name,
    className,
  } = props;
  const [internal, setInternal] = useState<number | undefined>(defaultValue);
  const id = useId();
  // Presence of value distinguishes an empty controlled field from uncontrolled use.
  const controlled = Object.hasOwn(props, "value");
  const current = controlled ? value : internal;
  const increment = Number.isFinite(step) && step > 0 ? step : 1;
  const update = (next: number | undefined) => {
    setInternal(next);
    onValueChange?.(next);
  };
  const bounded = (next: number) =>
    Math.max(
      min ?? -Infinity,
      Math.min(max ?? Infinity, Number(next.toPrecision(12))),
    );
  const change = (amount: number) =>
    update(bounded((current ?? min ?? 0) + amount));
  return (
    <div className={cn("space-y-2", className)}>
      <Label htmlFor={id}>{label}</Label>
      <div className="flex items-center gap-2">
        <Button
          type="button"
          size="icon"
          variant="outline"
          aria-label={`Decrease ${label}`}
          disabled={
            disabled ||
            (current !== undefined && min !== undefined && current <= min)
          }
          onClick={() => change(-increment)}
        >
          <Minus className="size-4" />
        </Button>
        <Input
          id={id}
          name={name}
          className="min-w-0 text-center tabular-nums"
          type="number"
          min={min}
          max={max}
          step={increment}
          required={required}
          disabled={disabled}
          value={current ?? ""}
          onChange={(event) =>
            update(
              Number.isFinite(event.currentTarget.valueAsNumber)
                ? event.currentTarget.valueAsNumber
                : undefined,
            )
          }
          onBlur={() => {
            if (current !== undefined) update(bounded(current));
          }}
        />
        <Button
          type="button"
          size="icon"
          variant="outline"
          aria-label={`Increase ${label}`}
          disabled={
            disabled ||
            (current !== undefined && max !== undefined && current >= max)
          }
          onClick={() => change(increment)}
        >
          <Plus className="size-4" />
        </Button>
      </div>
    </div>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

NumberField props
PropType / DefaultDescription
labelstring

Default: required

Visible field name and step-button context.
value / defaultValuenumber | undefined

Default: empty

Controlled or initial number.
onValueChange(value: number | undefined) => void

Default: —

Receives edits, including clearing the field.
min / maxnumber

Default: unbounded

Accepted bounds; applied on blur or step actions.
stepnumber

Default: 1

Positive step amount.
disabled / required / nameboolean / boolean / string

Default: —

Native form behavior.

Build with these too