Number Field
A numeric input element with increment and decrement buttons, and a scrub area. Built with Base UI and Tailwind CSS. Copy-paste ready.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<NumberField defaultValue={0}>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
Installation
pnpm dlx shadcn@latest add @cnippet/number-field
Usage
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field"<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>API Reference
NumberField
Root component. Styled wrapper for NumberField.Root from Base UI.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "lg" | "default" | Controls the size of all child components via context |
NumberFieldGroup
Container for the input and buttons. Styled wrapper for NumberField.Group from Base UI.
NumberFieldInput
Input element for the number value. Styled wrapper for NumberField.Input from Base UI.
NumberFieldIncrement
Button to increment the value. Styled wrapper for NumberField.Increment from Base UI with plus icon.
NumberFieldDecrement
Button to decrement the value. Styled wrapper for NumberField.Decrement from Base UI with minus icon.
NumberFieldScrubArea
Draggable area for scrubbing the value. Styled wrapper for NumberField.ScrubArea from Base UI.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | - | Label text displayed in the scrub area |
Examples
For accessible labelling and validation, prefer using the Field component to wrap number fields. See the related example: Number field.
Sizes
Renders the sm, default, and lg size variants to show how the number field scales across layout contexts.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<>
<NumberField defaultValue={0} size="sm">
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<NumberField defaultValue={0} size="lg">
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</>
);
}
Disabled
Prevents all interaction while keeping the current value visible in the input.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<NumberField defaultValue={42} disabled>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
With External Label
Associates an external Label with the number field via useId for accessible identification without Field.
import { useId } from "react";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
const id = useId();
return (
<div className="flex flex-col items-start gap-2">
<Label htmlFor={id}>Quantity</Label>
<NumberField defaultValue={0} id={id}>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
);
}
With Scrub
Adds a NumberFieldScrubArea above the input so users can drag left/right to adjust the value — useful for large ranges.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
With Range
Sets min and max constraints to clamp the value within a defined boundary, preventing out-of-range entries.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<NumberField defaultValue={5} max={10} min={0}>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
With Formatted Value
Applies a custom format function to display the value with a currency symbol, unit, or locale-specific formatting.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<NumberField
defaultValue={0}
format={{ currency: "USD", style: "currency" }}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
With Step
Sets a step value so each increment or decrement click moves the value by a fixed amount (e.g. 5 or 0.25).
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<div className="flex flex-col gap-6">
<NumberField defaultValue={0} step={10}>
<NumberFieldScrubArea label="Step 10" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<NumberField defaultValue={0} step={0.1}>
<NumberFieldScrubArea label="Step 0.1" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
);
}
Form Integration
Wraps the number field in Form and Field for label association, required validation, and a visible FieldError.
"use client";
import type { FormEvent } from "react";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Field } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
const schema = z.object({
quantity: z.coerce
.number({ message: "Please enter a quantity." })
.min(1, { message: "Quantity must be at least 1." })
.max(100, { message: "Maximum quantity is 100." }),
});
type Errors = Record<string, string | string[]>;
async function submitForm(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
const { fieldErrors } = z.flattenError(result.error);
return { errors: fieldErrors as Errors };
}
return {
data: result.data,
errors: {} as Errors,
};
}
export default function Particle() {
const [loading, setLoading] = useState(false);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
setLoading(true);
const response = await submitForm(event);
await new Promise((r) => setTimeout(r, 800));
setLoading(false);
if (Object.keys(response.errors).length === 0) {
alert(`Quantity: ${response.data?.quantity}`);
}
};
return (
<Form className="flex w-full max-w-64 flex-col gap-4" onSubmit={onSubmit}>
<Field name="quantity">
<NumberField defaultValue={1} max={100} min={1}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</Field>
<Button loading={loading} type="submit">
Submit
</Button>
</Form>
);
}
Percentage with Scrub
Uses format={{ style: "percent" }} with a 0–1 range and step={0.05}, rendered as a draggable scrub area for an opacity or volume control.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
export default function Particle() {
return (
<NumberField
defaultValue={0.25}
format={{ style: "percent" }}
max={1}
min={0}
step={0.05}
>
<NumberFieldScrubArea label="Opacity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
Shopping Cart Quantity
A compact product row pairing a thumbnail placeholder, name, price, and an inline NumberField for adjusting item count — common in cart and order summary UIs.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export function Pattern() {
return (
<div className="flex w-full max-w-xs items-center gap-4 rounded-lg border p-3">
<div className="size-14 shrink-0 rounded-md bg-muted" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium text-sm">Wireless Earbuds</span>
<span className="text-muted-foreground text-xs">$79.00</span>
</div>
<NumberField defaultValue={1} max={99} min={1}>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput className="w-10 text-center" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
);
}
Typography Settings
Three labeled rows (font size, line height, letter spacing) each with a NumberFieldScrubArea and unit suffix, forming a compact typographic control panel.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
const settings = [
{
defaultValue: 16,
label: "Font size",
max: 96,
min: 8,
step: 1,
unit: "px",
},
{
defaultValue: 1.5,
label: "Line height",
max: 3,
min: 1,
step: 0.1,
unit: "rem",
},
{
defaultValue: 0,
label: "Letter spacing",
max: 0.5,
min: -0.1,
step: 0.01,
unit: "em",
},
];
export function Pattern() {
return (
<div className="flex flex-col gap-3">
{settings.map(({ label, unit, defaultValue, min, max, step }) => (
<div className="flex items-center gap-3" key={label}>
<span className="w-28 shrink-0 text-right text-sm">{label}</span>
<NumberField
defaultValue={defaultValue}
max={max}
min={min}
step={step}
>
<NumberFieldScrubArea label={label} />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<span className="w-6 shrink-0 text-muted-foreground text-xs">
{unit}
</span>
</div>
))}
</div>
);
}
Min/Max Budget Range
Two side-by-side currency-formatted fields (Min and Max) with a dash separator — ideal for price range filters in search or marketplace UIs.
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
export function Pattern() {
return (
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1.5">
<span className="font-medium text-sm">Min</span>
<NumberField
defaultValue={100}
format={{ currency: "USD", style: "currency" }}
min={0}
step={50}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
<span className="mb-2.5 text-muted-foreground">–</span>
<div className="flex flex-col gap-1.5">
<span className="font-medium text-sm">Max</span>
<NumberField
defaultValue={1000}
format={{ currency: "USD", style: "currency" }}
min={0}
step={50}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
</div>
);
}
Controlled with Reset
A useState-controlled field paired with a ghost icon button that restores the value to its default — useful for settings panels where users can undo edits.
"use client";
import { RotateCcwIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
const DEFAULT_VALUE = 50;
export function Pattern() {
const [value, setValue] = useState(DEFAULT_VALUE);
return (
<div className="flex items-center gap-2">
<NumberField
onValueChange={(v) => setValue(v ?? DEFAULT_VALUE)}
value={value}
>
<NumberFieldScrubArea label="Volume" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<Button
aria-label="Reset to default"
onClick={() => setValue(DEFAULT_VALUE)}
size="icon"
variant="ghost"
>
<RotateCcwIcon className="size-4" />
</Button>
</div>
);
}
On This Page

