Number field
A number between known bounds, typed or stepped. A hidden input carries the plain number, so the form submits before hydration.
client - size not measured yet
Number field
client - size not measured yetA number between known bounds, typed or stepped. A hidden input carries the plain number, so the form submits before hydration.
Try it
size
invalid
fullWidth
min
max
The code
number-field.tsx
import { NumberField } from 'topclassui/number-field'
<div className="grid justify-items-start gap-2">
<label htmlFor="number-field-playground-guests" className="text-sm text-body">Guests</label>
<NumberField id="number-field-playground-guests" defaultValue={2} />
</div>min, max and step are clamped at runtime, so a typed or passed value cannot break the field.
Every state
4 statesImport from the package
topclassui/number-field
import { NumberField } from 'topclassui/number-field'Install once:npm i topclassui@beta zod
Examples
- For a count or an amount between known bounds: guests, seats, hours. Nothing bounds it by a count of characters.
- The form receives the plain number, never the formatted text: 1234.5, not 1,234.5. An empty field sends an empty value.
- A decimal needs a step it fits: with the default
stepof 1, a value of 12.5 fails the browser's check. Use 0.5 or any. - The field formats with the provider's locale and numbering system, so the server and the browser print the same text. Arabic-Indic digits are read in any locale.
- A stepper at its limit is disabled and dimmed. The steppers keep their 24px, 44px under a touch pointer, so a narrow field takes the room from the number.
basic
basic.tsx
import { NumberField } from 'topclassui/number-field'
/** Outside a Field: a native label points at the id, which Base UI gives to the visible input. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-2">
<label htmlFor="number-field-basic-guests" className="text-sm text-body">Guests</label>
<NumberField id="number-field-basic-guests" defaultValue={2} min={1} max={8} />
</div>
)
}
limits
limits.tsx
import { NumberField } from 'topclassui/number-field'
/** A value at its max, so the increase stepper is disabled and dimmed; and a step of 0.5, which a decimal needs. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
<NumberField name="seats" defaultValue={8} min={1} max={8} aria-label="Seats" />
<NumberField name="hours" defaultValue={1.5} min={0} max={12} step={0.5} aria-label="Hours" />
</div>
)
}
form
form.tsx
import { Button } from 'topclassui/button'
import { NumberField } from 'topclassui/number-field'
/** One field in a plain form: it sends guests=2, before any script runs and after. */
export default function Demo() {
return (
<form className="grid justify-items-start gap-2">
<label htmlFor="number-field-form-guests" className="text-sm text-body">Guests</label>
<NumberField id="number-field-form-guests" name="guests" defaultValue={2} min={1} max={8} />
<Button type="submit">Book</Button>
</form>
)
}
sizes
sizes.tsx
import { NumberField } from 'topclassui/number-field'
/** The five sizes. Each is as wide as an Input of its size, and every stepper stays 24 px, 44 under a finger. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
<NumberField size="xs" defaultValue={12} aria-label="Extra small" />
<NumberField size="sm" defaultValue={12} aria-label="Small" />
<NumberField size="md" defaultValue={12} aria-label="Medium" />
<NumberField size="lg" defaultValue={12} aria-label="Large" />
<NumberField size="xl" defaultValue={12} aria-label="Extra large" />
</div>
)
}
row
row.tsx
import { NumberField } from 'topclassui/number-field'
/** Two side by side: each shrinks to its share, as two Inputs would, and the page never scrolls. Under 6.5rem each, as at
* 200% text on a phone, the second goes under the first, so neither has a stepper cut. */
export default function Demo() {
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,6.5rem),max-content))] gap-2">
<NumberField defaultValue={2} min={0} aria-label="Adults" />
<NumberField defaultValue={0} min={0} aria-label="Children" />
</div>
)
}
In Arabic, right to left
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { NumberField } from 'topclassui/number-field'
/** Arabic, inside TopClassProvider as the root layout would mount it. Increase is at the left, the number stays
* left-to-right; the first writes Latin digits, as the provider does, the second Arabic-Indic ones from its locale. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-2">
<label htmlFor="number-field-rtl-change" className="text-sm text-body">الفرق</label>
<NumberField id="number-field-rtl-change" name="change" defaultValue={-5} min={-10} max={10} />
<label htmlFor="number-field-rtl-guests" className="text-sm text-body">عدد الضيوف</label>
<NumberField id="number-field-rtl-guests" name="guests" locale="ar-u-nu-arab" defaultValue={1234.5} step="any" />
</div>
</TopClassProvider>
)
}
Props
Keyboard and accessibility
- A name is required:
idfor a<label htmlFor>, oraria-label, oraria-labelledby. They land on the visible input. - The steppers are named in the provider's language, and they are not tab stops: the arrow keys do the same from the input.
invalidsets aria-invalid on the input, and the line of the field turns to danger and thickens.
Right to left
Increase sits at the left and the number stays left to right. Its digits follow the provider's numbering system.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.