NumberField
Enter a number with confidence. A bordered field shell pairs a numeric input with steppers, accepts keyboard arrows and pointer scrubbing, and formats the value — currency, percent, units, precision — for the reader's locale.
Basic
Compose the shell from its parts: a NumberField.Decrement, the NumberField.Input, and a NumberField.Increment inside one NumberField.Group. The steppers are ghost icon buttons that increment and decrement by the field's step; the input takes typed entry and clamps on blur. By default the steppers sit as a horizontal −/+ pair grouped at the inline end (steppers="end"). Give the input an aria-label (or wire it to a visible label) so it is named for assistive tech.
"use client";
import { NumberField } from "@stridge/noctis";
export default function NumberFieldBasic() {
return (
<NumberField.Root defaultValue={1} min={0}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Quantity" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
);
}
Formatting
The reason a number field beats a plain text input: pass Base UI's format (the full Intl.NumberFormat options) and the value displays as currency, a percent, a unit, or to a fixed precision — grouped and localized — while the committed value stays a plain number. Percent treats the value as a fraction (0.2 shows as 20%), so step by 0.01 to move 1% at a time.
"use client";
import { NumberField } from "@stridge/noctis";
export default function NumberFieldFormatting() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{/* Currency — Intl resolves the symbol, grouping, and two fraction digits for the locale. */}
<NumberField.Root defaultValue={1999.99} min={0} step={1} format={{ style: "currency", currency: "USD" }}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Price" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* Percent — the value is the fraction (0.2 shows as 20%); step by 1% with 0.01. */}
<NumberField.Root defaultValue={0.2} min={0} max={1} step={0.01} format={{ style: "percent" }}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Opacity" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* Unit — a sanctioned CLDR unit (megabyte → “MB”). For ad-hoc units like px, use an affix. */}
<NumberField.Root defaultValue={256} min={0} step={64} format={{ style: "unit", unit: "megabyte" }}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Memory" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* Fixed precision — always two decimals, stepping by 0.01. */}
<NumberField.Root defaultValue={3.14} step={0.01} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Coefficient" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
</div>
);
}
Affixes
For a unit Intl can't render — px, an ad-hoc symbol — seat a NumberField.Prefix or NumberField.Suffix inside the shell. They are segmented unit-hint cells mirroring Input's addon adornment — a quiet fill stepped off the field surface with a divider to the value — and are visual chrome only: non-interactive, never changing the committed value. Reach for format when the unit is a real currency or CLDR unit (so the value formats and localizes); reach for an affix when it is just a hint beside a plain number.
"use client";
import { NumberField } from "@stridge/noctis";
export default function NumberFieldAffix() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{/* A leading affix as a unit hint. The visible $ is chrome; the committed value is a plain number. */}
<NumberField.Root defaultValue={1000} min={0} step={50}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Prefix>$</NumberField.Prefix>
<NumberField.Input aria-label="Budget" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* A trailing affix for an ad-hoc unit Intl can't render (px is not a CLDR unit). */}
<NumberField.Root defaultValue={16} min={0} step={1}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Font size" />
<NumberField.Suffix>px</NumberField.Suffix>
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
</div>
);
}
Steppers
steppers sets where the increment/decrement buttons sit. end (the default) groups them as a horizontal −/+ pair at the inline end; split flanks the value; stacked blocks them in a sharp column at the inline end; none drops the buttons entirely — arrow keys and scrubbing still step the value, for dense toolbars where chrome is noise.
"use client";
import { NumberField } from "@stridge/noctis";
export default function NumberFieldSteppers() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
{/* end (default) — a horizontal −/+ pair grouped at the inline end. */}
<NumberField.Root defaultValue={3} min={0} steppers="end">
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="End steppers" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* split — the steppers flank the value. */}
<NumberField.Root defaultValue={3} min={0} steppers="split">
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Split steppers" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* stacked — a sharp block stacked at the inline end. */}
<NumberField.Root defaultValue={3} min={0} steppers="stacked">
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Stacked steppers" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
{/* none — the input only; arrow keys and scrubbing still step the value. */}
<NumberField.Root defaultValue={3} min={0} steppers="none">
<NumberField.Group>
<NumberField.Input aria-label="No steppers" />
</NumberField.Group>
</NumberField.Root>
</div>
);
}
Sizes
Two heights — medium (the default) and large — sharing the control rhythm. The steppers and type scale with the shell, so the buttons stay a comfortable target at both sizes.
"use client";
import { NumberField } from "@stridge/noctis";
const SIZES: NumberField.Size[] = ["md", "lg"];
export default function NumberFieldSizes() {
return (
<div className="flex flex-wrap items-center gap-6">
{SIZES.map((size) => (
<NumberField.Root key={size} size={size} defaultValue={1}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label={`Quantity (${size})`} />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
))}
</div>
);
}
Constraints
Bound the value with min and max, and set the increment with step. The steppers and arrow keys move by step and clamp at the bounds; at a bound the stepper that can no longer move greys out, so the limit is felt. Hold Shift for the large step, the meta key for the small step.
"use client";
import { NumberField } from "@stridge/noctis";
export default function NumberFieldConstraints() {
return (
<NumberField.Root defaultValue={50} min={0} max={100} step={5}>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Volume (0–100, steps of 5)" />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
);
}
Inside a field
The canonical usage — and the pattern the whole field family shares. Wrap the field in a Noctis Field.Root with a Field.Label, Field.Description, and Field.Error: the label auto-associates, the description and error join the input through aria-describedby, and aria-invalid flips from validation — driving the shell's danger border, which wins over the focus border when both apply. Put bounds and step context in the Field.Description (not the placeholder).
How many seats to reserve (1–10).
"use client";
import { Field, NumberField } from "@stridge/noctis";
export default function NumberFieldField() {
return (
<Field.Root className="w-full max-w-sm">
<Field.Label>Seats</Field.Label>
<NumberField.Root defaultValue={2} min={1} max={10} required>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input />
<NumberField.Increment />
</NumberField.Group>
</NumberField.Root>
<Field.Description>How many seats to reserve (1–10).</Field.Description>
<Field.Error match="rangeOverflow">That is more than the ten available.</Field.Error>
<Field.Error match="valueMissing">Enter a number of seats.</Field.Error>
</Field.Root>
);
}
Scrubbing
Wrap a label or handle in NumberField.ScrubArea to let the reader drag horizontally to change the value — the pointer gesture familiar from design tools. pixelSensitivity tunes how far the pointer travels per step; a NumberField.ScrubAreaCursor inside the area becomes a virtual cursor that follows the pointer 1:1 during the drag. The input and steppers stay available for precise entry.
"use client";
import { NumberField } from "@stridge/noctis";
export default function NumberFieldScrubbing() {
return (
<NumberField.Root defaultValue={120} min={0}>
<div className="flex items-center gap-3">
{/*
* Drag the label to scrub. `pixelSensitivity` sets how many pixels of movement equal one
* step; the ScrubAreaCursor is the virtual cursor that follows the pointer during the drag.
*/}
<NumberField.ScrubArea
pixelSensitivity={3}
className="cursor-ew-resize text-small font-medium text-muted select-none"
>
Width
<NumberField.ScrubAreaCursor />
</NumberField.ScrubArea>
<NumberField.Group>
<NumberField.Decrement />
<NumberField.Input aria-label="Width" />
<NumberField.Increment />
</NumberField.Group>
</div>
</NumberField.Root>
);
}
Keyboard
The input owns the keyboard; the steppers are pointer targets and stay out of the tab order. All directions are absolute — Up always increases — so they do not mirror under RTL.
| Key | Action |
|---|---|
Arrow Up | Increase by step |
Arrow Down | Decrease by step |
Shift + Arrow Up / Shift + Arrow Down | Step by largeStep (default 10) |
Meta + Arrow Up / Meta + Arrow Down | Step by smallStep (default 0.1) |
Page Up / Page Down | Step by largeStep |
Home / End | Jump to min / max (when bounded) |
Accessibility
- Role. The input keeps Base UI's
role="textbox"witharia-roledescription="Number field", rather thanrole="spinbutton". This is deliberate: a textbox formats and accepts free-form locale-aware entry (Persian numerals, grouping, currency) that the spinbutton model fights, and the formatted value is reflected as the input's value, so a screen reader reads it on focus and after each step.aria-valuenow/valuemin/valuemaxare spinbutton/slider properties — invalid on a textbox — so they are intentionally omitted; convey bounds and step in aField.Descriptioninstead. - Steppers. The increment/decrement buttons keep accessible names (the translated
Increase/Decrease) but stay out of the tab order (tabindex="-1"), since the arrow keys mirror them and focus never leaves the input. - At a bound. The stepper that can no longer move is greyed and
aria-disabled, so the limit is perceivable, not silent. - Mobile. The input requests the right soft keyboard automatically —
inputMode="decimal"when the field admits fractions (a fractionalstep, a percent, or a format with fraction digits), elseinputMode="numeric". - RTL. Directions are absolute (Up always increases) and never mirror; the shell, affixes, and stepper block flip sides logically.
Anatomy
Compose the field from its parts. NumberField.Root owns the value (controlled via value / onValueChange, or uncontrolled via defaultValue), the shared size, and the steppers layout.
NumberField.Root— the container and value owner. Props:size(defaultmd),steppers(defaultend), plus the Base UINumberField.Rootprops below.NumberField.Group— the bordered field shell. Wraps the affixes, input, and steppers, carries the focus border, and stampsdata-steppers.NumberField.Input— the numeric input. Give it anaria-label(or an associatedField.Label) so it is named; it derives itsinputModefromformat/step.NumberField.Prefix/NumberField.Suffix— optional leading/trailing unit-hint affixes (muted, non-interactive). Visual chrome only.NumberField.Decrement/NumberField.Increment— the down and up steppers, ghost icon buttons. Passchildrento swap the default minus/plus glyph; the accessible name is the translatedDecrease/Increaselabel.NumberField.ScrubArea— an optional drag handle that scrubs the value by horizontal pointer movement (direction,pixelSensitivity).NumberField.ScrubAreaCursor— the virtual cursor shown inside the scrub area during a drag.
Base UI pass-through props (set on NumberField.Root, forwarded as-is): min / max (bounds), step (arrow/stepper increment), smallStep (meta key, default 0.1), largeStep (shift / Page, default 10), snapOnStep (snap to the nearest step multiple), format (Intl.NumberFormat options), locale (overrides the injected locale), allowWheelScrub (opt-in wheel-to-change), allowOutOfRange (permit values past the bounds), and onValueCommitted (fires on blur/release, not every keystroke).
Every part carries a data-slot (number-field, number-field-group, number-field-input, number-field-prefix, number-field-suffix, number-field-increment, number-field-decrement, number-field-scrub-area, number-field-scrub-area-cursor) for host-side styling — pair it with the state attributes (data-size, data-steppers, data-disabled, data-readonly, data-scrubbing, and the data-valid / data-invalid / data-focused a wrapping Field.Root adds). The field is RTL-aware and respects prefers-reduced-motion.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every number field in that region retunes — e.g. .dense { --noctis-number-field-stepper-width: var(--noctis-size-control-xs); } narrows the steppers. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through. Expand a row for the full type and description.