NumberField
FormLabeled numeric entry with bounded increment and decrement controls.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { NumberField } from "@/components/ui/number-field";
import { useState } from "react";
export default function NumberFieldExample() {
const [seats, setSeats] = useState<number | undefined>(5);
return (
<div className="w-full max-w-xs space-y-5">
<NumberField
label="Seats"
min={1}
max={20}
value={seats}
onValueChange={setSeats}
/>
<NumberField
label="Unit price"
min={0}
max={500}
step={0.25}
defaultValue={24.5}
/>
<NumberField label="Managed allocation" disabled defaultValue={100} />
<p role="status" className="text-xs text-muted-foreground">
Seats: {seats ?? "empty"}. Values clamp to bounds when focus leaves the
input.
</p>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible field name and step-button context. |
| value / defaultValue | number | undefinedDefault: empty | Controlled or initial number. |
| onValueChange | (value: number | undefined) => voidDefault: — | Receives edits, including clearing the field. |
| min / max | numberDefault: unbounded | Accepted bounds; applied on blur or step actions. |
| step | numberDefault: 1 | Positive step amount. |
| disabled / required / name | boolean / boolean / stringDefault: — | Native form behavior. |