Constraining Number Inputs with min, max, and step
A <input type="number"> field only enforces a numeric range when its min, max, and step attributes are set correctly and read back through the Constraint Validation API; this page shows the exact attribute combinations that produce rangeUnderflow, rangeOverflow, and stepMismatch, and how to surface them without leaking the browser’s default bubbles.
When to Use This Native Range Approach
Reach for min/max/step when the constraint is a pure numeric bound that the browser can evaluate synchronously on the client — quantities, ages, prices, percentages. It is the cheapest correct option because the engine computes overflow and step alignment for free, before any JavaScript runs.
- Use native range attributes when the valid set is a contiguous numeric interval or an arithmetic progression (0, 5, 10, …).
- Prefer the HTML5 Pattern Attribute Regex Examples when the value is a formatted string (a SKU, a postcode) rather than a true number.
- Escalate to asynchronous server checks only when the bound depends on live data the client cannot know, such as remaining stock.
The novalidate + checkValidity() Baseline
Ship the form with novalidate so the browser suppresses its own bubble timing, then drive the single verdict yourself through reportValidity(). This keeps every field on one code path and lets you decorate errors consistently.
const form = document.querySelector<HTMLFormElement>("#order-form")!;
const qty = form.elements.namedItem("quantity") as HTMLInputElement;
// The attributes below are the actual constraint. JS only reads the verdict.
// <input type="number" name="quantity" min="1" max="99" step="1" required>
form.addEventListener("submit", (event: SubmitEvent) => {
// novalidate means the browser will not block submission for us —
// we ask for the verdict explicitly through the Constraint Validation API.
if (!form.reportValidity()) {
event.preventDefault(); // stop the invalid submit; reportValidity has focused
// the first invalid control and shown its message
return;
}
event.preventDefault();
const value = qty.valueAsNumber; // a real number, never a string
console.log("submitting quantity", value);
});
// Optional: read *why* a field is invalid to tailor the message.
qty.addEventListener("input", () => {
const v = qty.validity;
if (v.rangeUnderflow) qty.setCustomValidity(`Minimum is ${qty.min}.`);
else if (v.rangeOverflow) qty.setCustomValidity(`Maximum is ${qty.max}.`);
else if (v.stepMismatch) qty.setCustomValidity(`Use steps of ${qty.step}.`);
else qty.setCustomValidity(""); // clears the custom error so the field can pass
});
The setCustomValidity("") reset is mandatory: a non-empty custom message keeps the field invalid forever. For the full contract, see How to Use setCustomValidity Correctly and the broader treatment of custom validity messages.
Attribute Reference: min, max, step and Their ValidityState Flags
| Option | Type | Default | Purpose |
|---|---|---|---|
min |
number | none | Lower bound; a smaller value sets validity.rangeUnderflow. |
max |
number | none | Upper bound; a larger value sets validity.rangeOverflow. |
step |
number | "any" |
1 |
Allowed increment from the step base; a misaligned value sets validity.stepMismatch. |
| step base | number | value of min, else 0 |
The origin the step is measured from; min="2" step="3" accepts 2, 5, 8, … |
required |
boolean | false |
Empty field sets validity.valueMissing. |
valueAsNumber |
number (read) | NaN |
Parsed numeric value; NaN when the box is empty or unparseable. |
Note that step="any" disables step alignment entirely, which is the correct choice for free-form decimals like currency where step="0.01" would otherwise reject legitimately rounded values.
Verification Steps
import { test, expect } from "@playwright/test";
test("quantity rejects out-of-range and misaligned values", async ({ page }) => {
await page.goto("/order");
const qty = page.getByLabel("Quantity");
await qty.fill("0");
expect(await qty.evaluate((el: HTMLInputElement) => el.validity.rangeUnderflow)).toBe(true);
await qty.fill("2.5");
expect(await qty.evaluate((el: HTMLInputElement) => el.validity.stepMismatch)).toBe(true);
await qty.fill("50");
expect(await qty.evaluate((el: HTMLInputElement) => el.checkValidity())).toBe(true);
});
Edge Cases & Failure Modes
Floating-point step drift. step="0.1" flags many decimals as stepMismatch because binary floats cannot represent tenths exactly. Fix it by scaling to integers — use step="1" on a “tenths” field and divide on submit — or set step="any" and enforce precision through Zod schema validation after the native pass.
A non-numeric string reads as empty. If a user pastes 12abc, most browsers refuse the keystrokes, but programmatic value = "12abc" leaves valueAsNumber as NaN and only badInput fires, not rangeUnderflow. Always branch on Number.isNaN(input.valueAsNumber) before trusting a numeric comparison, and route the message through accessible error messaging rather than a raw bubble.
A dependent max that outruns the field. When the ceiling comes from another control — “guests cannot exceed room capacity” — a static max cannot express it. That is cross-field validation: recompute max in an input listener, or reconcile the two values in the submit handler before calling reportValidity().
How the Step Base Shifts the Valid Set
The single most misread part of step is that it is not measured from zero. The specification defines a step base: the value alignment is computed against. That base is the min attribute when present, otherwise it defaults to 0. So min="2" step="3" does not accept 3, 6, 9 — it accepts 2, 5, 8, 11, because every allowed value is min + (n × step) for a non-negative integer n. Forgetting this is the usual reason a field rejects a number the author expected it to accept.
Two consequences follow directly. First, changing min silently re-tiles the entire valid set, so a “quick fix” to the lower bound can invalidate values that were passing a moment earlier. Second, when there is no min, negative inputs are still aligned against 0, which is why step="5" cleanly accepts -10 and -5. You can confirm the base the browser is actually using without guessing by asking the element to snap a candidate value for you:
// Report every allowed value near what the user typed, using the browser's
// own step arithmetic rather than reimplementing modulo math by hand.
function nearestValidSteps(input: HTMLInputElement): {
down: number | null;
current: number;
up: number | null;
} {
const current = input.valueAsNumber;
// stepDown/stepUp mutate value, so snapshot and restore around the probe.
const original = input.value;
let down: number | null = null;
let up: number | null = null;
try {
input.stepDown(); // clamps to the previous aligned value, honouring min
down = input.valueAsNumber;
} catch {
down = null; // throws when already at the floor or value is NaN
}
input.value = original;
try {
input.stepUp();
up = input.valueAsNumber;
} catch {
up = null;
}
input.value = original; // leave the field exactly as the user left it
return { down, current, up };
}
stepUp() and stepDown() are the authoritative source of truth here: they apply the same base-and-increment logic the validator uses, clamp at min/max, and throw a DOMException when there is nowhere left to move. Using them to build the “did you mean 5 or 10?” hint means your suggestion can never disagree with the flag the browser will raise.
Announcing Range Errors to Assistive Technology
A red border satisfies sighted users but says nothing to a screen reader. The Constraint Validation API sets stepMismatch or rangeOverflow, but it does not wire the element to any accessible description — that is on you. Reflect the invalid state with aria-invalid and point the field at a live message container with aria-describedby, updating both from the same input listener that computes the message:
const qty = document.querySelector<HTMLInputElement>("#quantity")!;
const status = document.querySelector<HTMLElement>("#quantity-error")!;
// status markup: <p id="quantity-error" role="status" aria-live="polite"></p>
function syncAccessibleState(): void {
const v = qty.validity;
qty.setAttribute("aria-invalid", String(!qty.checkValidity()));
if (v.valid) {
status.textContent = "";
return;
}
if (v.rangeUnderflow) status.textContent = `Enter ${qty.min} or more.`;
else if (v.rangeOverflow) status.textContent = `Enter ${qty.max} or less.`;
else if (v.stepMismatch) status.textContent = `Use multiples of ${qty.step}.`;
else if (v.badInput) status.textContent = "Enter a number.";
}
qty.addEventListener("input", syncAccessibleState);
Prefer role="status" with aria-live="polite" over role="alert" for inline field validation: a polite region waits for a pause in typing before announcing, so it does not interrupt every keystroke while the user is mid-entry. Reserve the assertive alert role for the submit-time summary, where an immediate interruption is warranted. The broader technique is covered in inline error messaging strategies.
Frequently Asked Questions
Why does step="0.1" reject valid decimals like 0.3?
The browser measures alignment in binary floating point, where 0.3 is not exactly three tenths, so stepMismatch can fire on values that look valid. Use step="any" and validate precision in JavaScript, or scale the field to integers so the step is a whole number.
Does min/max work without type="number"?
The min and max attributes only produce range validity on inputs that hold a numeric or date-like value: number, range, date, time, and friends. On a plain text input they are inert, so use pattern or a length constraint instead.
Should I read value or valueAsNumber?
Use valueAsNumber for any arithmetic or comparison: it returns a real number and yields NaN for an empty or unparseable field, which is easy to guard. The string value is only right when you need the raw text exactly as typed.
Why does min="2" step="3" reject the value 6?
Step alignment is measured from the step base, which is the min value when one is set — not from zero. With min="2" step="3" the accepted values are 2, 5, 8, 11, … so 6 fails as a stepMismatch. Drop min or set it to a multiple of the step if you want 3, 6, 9 to pass.
Do stepUp() and stepDown() trigger validation events?
No. Calling stepUp() or stepDown() from script changes value but does not dispatch input or change events, so listeners that recompute your error state will not run. Dispatch new Event("input", { bubbles: true }) yourself after adjusting the value if UI needs to react. Both methods throw a DOMException when the value is NaN or already at the bound.
Related Guides
- Reading ValidityState Flags — decode
rangeUnderflow,rangeOverflow, andstepMismatchinto precise messages. - checkValidity vs reportValidity — choose the silent check versus the one that shows UI and moves focus.
- How to Use setCustomValidity Correctly — replace the default range bubble without trapping the field in an invalid state.
- HTML5 Pattern Attribute Regex Examples — the string-shaped counterpart when a value is not a true number.
- Prevent Default Submission Without Losing Validation — keep the manual
reportValidity()gate while handling submit yourself.