NumberInput
Lets users enter a numeric value by typing or stepping.
Installation
- Install the package.
npm install @hashintel/ds-components- Import the component.
import { NumberInput } from "@hashintel/ds-components";
export const RetryLimitInput = ({
value,
onChange,
}: {
value: number | null;
onChange: (value: number | null) => void;
}) => (
<NumberInput
value={value}
onChange={onChange}
min={0}
max={10}
width="xs"
aria-label="Retry limit"
/>
);When to use
- Do: Use NumberInput when the stored value is a number: counts, limits, timeouts, numeric entity properties.
- Do: Set
min,max,stepandinputModeto match the value's constraints — stepping stays in range and mobile devices get the matching keyboard. - Do: Wrap it in
Form.Fieldfor a label, description, and error messages — the label connects to the input automatically.
When not to use
- Don't: Don't use it for numeric codes — IDs, phone numbers, postal codes — where leading zeros matter and stepping makes no sense. Use TextInput with
inputMode="numeric". - Don't: Don't use it when the rough position in a bounded range matters more than the exact figure — use Slider.
- Don't: Don't expect formatted text while typing (thousands separators, currency symbols) — the field holds a plain number. Use
styledValuefor a formatted unfocused display and a prefix or suffix for symbols.
Examples
Basic
NumberInput is controlled: pass value and take the parsed number from onChange, which reports null when the field is empty.
import { useState } from "react";
import { NumberInput } from "@hashintel/ds-components";
export const BatchSizeInput = () => {
const [batchSize, setBatchSize] = useState<number | null>(32);
return (
<NumberInput
value={batchSize}
onChange={(value) => setBatchSize(value)}
min={1}
max={512}
width="xs"
aria-label="Batch size"
/>
);
};Decimal values
step defaults to 1, which makes an integer input: decimal-separator keys are rejected and parsed values are truncated. A fractional step — or "any" — switches to decimal mode and the decimal mobile keyboard.
Minimum and maximum
min defaults to 0 — pass a negative min to allow negative values. The bounds constrain stepping only: users can still type outside the range, so validate the value and set invalid yourself.
States
Disabled, invalid, and loading, in that order. invalid is presentational — an out-of-range typed value doesn't set it automatically.
Read-only
readonly renders the value as plain text — no border, stepper, or focus ring — for view modes that later switch to editing. Unlike disabled, it reads as content rather than a blocked control.
Subtle variant
subtle drops the border and background so the field reads as inline text until hovered or focused. Use it for in-place editing in dense surfaces; keep default in forms.
Sizes
md is the default. Match the size of neighbouring inputs and buttons; use xxs–sm in dense chrome such as table rows and toolbars.
Widths
Width presets align fields across form rows: xs, sm, md, lg, plus fullWidth (the default, stretching to the container) and fitContent, which hugs the current value. Set a custom width with className when alignment doesn't matter.
Sizing to the maximum value
width="maxNumber" sizes the field to exactly fit max at step's precision. It assumes the stepper is hidden, so pair it with hideStepper, and it needs a finite max.
Hiding the stepper
The spin buttons show only while the field is focused but reserve their space; hideStepper removes them and reclaims it. Hide the stepper when values are typed rather than nudged.
Clearable
clearable shows a clear button on hover or focus; clearing reports null to onChange and refocuses the input. Pass { onClear } to run your own reset instead.
Units with prefix and suffix
Put the unit in an adornment so users enter only the number. Text adornments take { text }, icons { iconName }, and either becomes a button when given onClick.
Formatted display
styledValue overlays a custom rendering while the field is unfocused — thousands separators, highlights — and reveals the raw number for editing on focus.
Connected inputs
Set connectToRightInput on the left input and connectToLeftInput on the
right to join a pair into one visual control. Only the default variant
connects — subtle and readonly inputs render separately.
In a form
Inside Form.Field the label connects to the input automatically. Keep invalid and the errors list in step so the message explains the red border.
import { Form, NumberInput } from "@hashintel/ds-components";
export const MaxTokensField = ({
value,
error,
onChange,
}: {
value: number | null;
error?: string;
onChange: (value: number | null) => void;
}) => (
<Form.Field
label="Max tokens"
description="Upper limit for each flow step"
errors={error ? [error] : undefined}
>
<NumberInput
value={value}
onChange={onChange}
min={1}
max={128000}
width="sm"
invalid={!!error}
/>
</Form.Field>
);Behavior
- An integer
stepmakes an integer input: decimal-separator and exponent keys are blocked with a brief grey flash, and parsed values are truncated. A fractional step (or"any") accepts every locale's decimal separator. - Typed values are never clamped: arrows and spin buttons stay within
min/max, but typing can leave the range. Validate the value and setinvalid. - Scrolling over a focused field doesn't change the value — the wheel is disabled on focus to prevent accidental edits.
- Spin buttons appear on focus and reserve their space while hidden, so the layout never shifts; Firefox never shows them.
- The minimum width fits two digits plus padding — much narrower than other inputs — so number fields sit comfortably in table rows.
Content
- Name the quantity in the label and put the unit in a suffix adornment: "Memory limit" plus "MB", not "Memory limit in megabytes".
- Use the placeholder for an example value ("e.g. 512"), never as the label — it disappears as soon as users type.
- Put the valid range in the field description, and template errors as "[Field] must be between [min] and [max]": "Batch size must be between 1 and 512."
Accessibility
NumberInput renders a native <input type="number">, which implements the WAI-ARIA spinbutton pattern — arrow-key stepping and numeric semantics come from the browser.
What you must do
- Always label the input — via
Form.Field, your own label withhtmlForId, oraria-label. - Pair
invalidwith a visible message (for exampleForm.Fielderrors) — color alone doesn't explain the problem. - Surface the valid range in the label or description — rejected keystrokes and stepping limits only flash the field, they don't announce why.
API reference
NumberInput
| Prop | Type | Description |
|---|---|---|
| value | number | null | undefined | |
| onChange | (value: number | null, event: ChangeEvent<HTMLInputElement, Element>) => void | |
| onClick | MouseEventHandler<Element> | |
| size | "xxs" | "xs" | "sm" | "md" | "lg" default: "md" | The size (height) of the element |
| placeholder | string | |
| step | number | "any" default: 1 | |
| max | number default: Number.MAX_SAFE_INTEGER | |
| prefix | PrefixOrSuffix | Element rendered at the start of the input. |
| suffix | PrefixOrSuffix | Element rendered at the end of the input. |
| variant | "subtle" | "default" default: "default" | The input's visual style: subtle removes the border so it reads as inline text. |
| loading | boolean | Whether a loading spinner is shown inside the input. |
| disabled | boolean | Set to show the input as disabled |
| tabIndex | number | |
| autoFocus | boolean | "never" | Set to true to make the element focused on mount or 'never' to prevent the item being auto-focused |
| onKeyDown | KeyboardEventHandler<Element> | |
| onFocus | FocusEventHandler<HTMLInputElement> | The onFocus handler |
| onBlur | FocusEventHandler<HTMLInputElement> | The onBlur handler |
| min | number default: 0 | |
| required | boolean | Marks the input as required: sets the native required attribute, so assistive technology announces it and form validation blocks empty submits. Purely functional — the input looks unchanged; pair it with Form.Field's required for the visible mark. |
| name | string | The name of the input |
| invalid | boolean | Set to show the input as invalid |
| inputMode | "search" | "text" | "none" | "email" | … 4 more | The keyboard that should be used on mobile devices. |
| readonly | boolean | Whether editing is disabled. Unlike disabled, strips the input styles and renders the value as plain text. |
| align | "left" | "right" | "center" default: "left" | The alignment of the text inside the input. |
| connectToLeftInput | boolean | Whether this input renders as connected to the input on its left. Set the matching prop on both inputs; subtle and readonly inputs are never connected. |
| connectToRightInput | boolean | Whether this input renders as connected to the input on its right. Set the matching prop on both inputs; subtle and readonly inputs are never connected. |
| styledValue | ReactNode | A formatted view of the value, shown while the input is unfocused. |
| clearable | boolean | { onClear: () => void; } | Whether the input can be cleared. true empties it through the normal change pipeline (onChange fires with ""); pass { onClear } to control clearing yourself; false reserves the clear button's space without enabling it. |
| showEditIcon | boolean | |
| autocomplete | boolean default: false | Whether the browser may autocomplete the input. |
| pattern | string | |
| width | "xs" | "sm" | "md" | "lg" | "fullWidth" | "fitContent" | "maxNumber" default: "fullWidth" | Preset input width. maxNumber sizes the input to fit the max value when one is set; assumes the stepper is hidden. |
| hideStepper | boolean |
PrefixOrSuffix
The object shape prefix and suffix take.
| Prop | Type | Description |
|---|---|---|
| iconName | "function" | "filter" | "at" | "circle" | … 137 more | Fills the slot with a design-system icon — a slot takes exactly one of iconName, text, or content. |
| text | string | Fills the slot with a short text label, such as a unit or protocol. |
| content | ReactNode | Fills the slot with arbitrary content. |
| onClick | (() => void) | (() => void) | Makes the slot a button. |
| disabled | boolean | Disables just the slot's button. |
| variant | "subtle" | "default" default: "default" | subtle blends the slot into the field, with no divider from the input text. |
Was this page helpful?