ComponentsNumberInput
NumberInput
Collect a number with optional minimum, maximum, and step values.
On this page
Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.
View registry filesImplemented with React and browser APIs, without an additional behavior or rendering library.
Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.
Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.
Import the component into your page or component file.
import { NumberInput } from "@dethink/components";Try the examples, then open the code to use them in your app.
Bounded quantity
type=number gives native min/max/step behavior — arrow keys and spinners step within bounds.
Between 1 and 500 seats.
Show sourceexamples/number-input/basic.tsx
"use client";
import {
Field,
FieldControl,
FieldDescription,
FieldLabel,
NumberInput,
} from "@dethink/components";
export function NumberInputBasic() {
return (
<div className="mx-auto max-w-xs">
<Field id="ni-seats">
<FieldLabel>Seats</FieldLabel>
<FieldControl asChild>
<NumberInput
name="seats"
type="number"
min={1}
max={500}
step={1}
defaultValue={5}
/>
</FieldControl>
<FieldDescription>Between 1 and 500 seats.</FieldDescription>
</Field>
</div>
);
}Keypad modes and states
numberMode switches the mobile keypad between decimal and digits-only; disabled and invalid states flow through Field.
Show sourceexamples/number-input/modes.tsx
"use client";
import {
Field,
FieldControl,
FieldLabel,
NumberInput,
} from "@dethink/components";
export function NumberInputModes() {
return (
<div className="mx-auto grid max-w-md gap-5 sm:grid-cols-2">
<Field id="ni-amount">
<FieldLabel>Amount (decimal keypad)</FieldLabel>
<FieldControl asChild>
<NumberInput numberMode="decimal" placeholder="19.99" />
</FieldControl>
</Field>
<Field id="ni-code">
<FieldLabel>Verification code (numeric keypad)</FieldLabel>
<FieldControl asChild>
<NumberInput
numberMode="numeric"
placeholder="123456"
maxLength={6}
/>
</FieldControl>
</Field>
<Field id="ni-disabled" disabled>
<FieldLabel>Disabled</FieldLabel>
<FieldControl asChild>
<NumberInput type="number" defaultValue={42} />
</FieldControl>
</Field>
<Field id="ni-invalid" invalid>
<FieldLabel>Invalid</FieldLabel>
<FieldControl asChild>
<NumberInput type="number" defaultValue={-3} min={0} />
</FieldControl>
</Field>
</div>
);
}Examples that combine components for common tasks.
Budget allocator
The 100% invariant lives across all three inputs, so every field flips invalid together, a stacked bar visualizes the split, and a live region explains the group-level error.
Budget fully allocated.
Show sourceexamples/number-input/recipe-allocator.tsx
"use client";
import { useState } from "react";
import {
Field,
FieldControl,
FieldLabel,
NumberInput,
} from "@dethink/components";
const channels = [
{ id: "search", label: "Search ads" },
{ id: "social", label: "Social" },
{ id: "content", label: "Content" },
] as const;
type Allocation = Record<(typeof channels)[number]["id"], number>;
/**
* Cross-field validation: each input is fine on its own — the invariant
* lives across all three, so every field flips invalid together and a live
* region explains the group-level error.
*/
export function NumberInputRecipeAllocator() {
const [allocation, setAllocation] = useState<Allocation>({
search: 50,
social: 30,
content: 20,
});
const total = channels.reduce(
(sum, channel) => sum + (allocation[channel.id] || 0),
0,
);
const balanced = total === 100;
return (
<div className="mx-auto max-w-sm space-y-4">
<div className="grid grid-cols-3 gap-3">
{channels.map((channel) => (
<Field
key={channel.id}
id={`alloc-${channel.id}`}
invalid={!balanced}
>
<FieldLabel className="text-sm">{channel.label}</FieldLabel>
<FieldControl asChild>
<NumberInput
type="number"
min={0}
max={100}
value={allocation[channel.id]}
onChange={(event) =>
setAllocation((current) => ({
...current,
[channel.id]: Number(event.target.value) || 0,
}))
}
/>
</FieldControl>
</Field>
))}
</div>
<div className="space-y-1.5">
<div
aria-hidden="true"
className="bg-muted flex h-2 overflow-hidden rounded-full"
>
{channels.map((channel, index) => (
<div
key={channel.id}
className={
index === 0
? "bg-primary"
: index === 1
? "bg-info"
: "bg-success"
}
style={{ width: `${Math.min(allocation[channel.id], 100)}%` }}
/>
))}
</div>
<p
aria-live="polite"
className={`text-sm ${balanced ? "text-muted-foreground" : "text-destructive font-medium"}`}
>
{balanced
? "Budget fully allocated."
: `Allocations must total 100% — currently ${total}%.`}
</p>
</div>
</div>
);
}NumberInput renders a real input element, so all native numeric attributes apply.
| Prop | What it does | Default |
|---|---|---|
numberMode"decimal" | "numeric" | Sets inputMode so mobile keyboards show the right keypad — decimals or digits only. | "decimal" |
type"text" | "number" | Use "number" for native min/max/step semantics and arrow-key stepping; "text" keeps free-form entry with a numeric keypad. | "text" |
controlSize"sm" | "md" | "lg" | Height and typography scale aligned with Input. | "md" |
invalidboolean | Shows an error style and marks the field as invalid for screen readers. | false |
…native input propsInputHTMLAttributes | Renders a real input: min, max, step, name, disabled, and form behavior are native. | Not set |