Most custom form systems either ignore the browser’s built-in validation entirely — reimplementing required, maxlength and type="email" in JavaScript — or leave it on and end up with native bubbles appearing on top of their own error messages.

There is a middle path that the form validation lifecycle benefits from: let the browser evaluate the constraints you declared in HTML, read its verdict through ValidityState, suppress its UI with novalidate, and render everything through your own error components. Your custom rules join the same channel through setCustomValidity, so the form has one notion of validity.


Context and prerequisites

The Constraint Validation API gives every form control:

  • el.validity — a ValidityState with booleans: valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, stepMismatch, badInput, customError, and the summary valid.
  • el.validationMessage — the browser’s localised message for the first failing constraint.
  • el.checkValidity() — returns the boolean and fires an invalid event if false.
  • el.reportValidity() — the same, but also shows the native bubble.
  • el.setCustomValidity(msg) — marks the control invalid with your message (customError); an empty string clears it.

Adding novalidate to the form stops the browser from blocking submission and showing bubbles, but the validity state is still computed. That is the key: you keep the evaluation and discard only the presentation.

What novalidate turns off, and what it keeps With novalidate on the form, the browser no longer blocks submission and no longer shows its native bubble. It still computes the validity state for every control, still evaluates required, pattern, type, min, max and length constraints, still supports setCustomValidity, and still applies the invalid and user-invalid pseudo-classes. Your code reads that state and renders its own messages. Turned off by novalidate Blocking submit on invalid controls. The native error bubble. Auto-focusing the first invalid control. Still working validity flags on every control. required, pattern, type, min/max, length. setCustomValidity and customError. :invalid and :user-invalid styling hooks.

The core pattern: native checks, custom rules, one renderer

type Messages = Partial<Record<keyof ValidityState, string>>;

// Your own copy for each native failure, so wording matches the rest of the UI
// instead of varying by browser and OS language.
const DEFAULT_MESSAGES: Messages = {
  valueMissing: "This field is required.",
  typeMismatch: "Enter a value in the expected format.",
  patternMismatch: "Check the format of this value.",
  tooShort: "This is too short.",
  tooLong: "This is too long.",
  rangeUnderflow: "This value is too low.",
  rangeOverflow: "This value is too high.",
  stepMismatch: "Use a whole number of steps.",
  badInput: "Enter a number.",
};

const ORDER: (keyof ValidityState)[] = [
  "badInput", "valueMissing", "typeMismatch", "patternMismatch",
  "tooShort", "tooLong", "rangeUnderflow", "rangeOverflow", "stepMismatch", "customError",
];

export type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;

export function messageFor(el: FormControl, overrides: Messages = {}): string | null {
  if (el.validity.valid) return null;
  for (const key of ORDER) {
    if (!el.validity[key]) continue;
    // customError carries its own text, set by setCustomValidity below.
    if (key === "customError") return el.validationMessage;
    return overrides[key] ?? el.dataset[`msg${key[0].toUpperCase()}${key.slice(1)}`] ?? DEFAULT_MESSAGES[key] ?? el.validationMessage;
  }
  return el.validationMessage;
}

// Custom rules feed the SAME validity model via setCustomValidity.
export function applyCustomRule(el: FormControl, rule: (value: string) => string | null) {
  // Clear first: a stale custom error would make every native flag irrelevant
  // (customError keeps validity.valid false until explicitly cleared).
  el.setCustomValidity("");
  if (!el.validity.valid) return;          // native failure takes precedence
  const msg = rule(el.value);
  if (msg) el.setCustomValidity(msg);
}

export function validateForm(form: HTMLFormElement, rules: Record<string, (v: string) => string | null>) {
  const errors: Record<string, string> = {};
  for (const el of Array.from(form.elements) as FormControl[]) {
    if (!el.name || !("validity" in el) || !el.willValidate) continue; // willValidate is false for disabled/readonly
    if (rules[el.name]) applyCustomRule(el, rules[el.name]);
    const msg = messageFor(el);
    if (msg) errors[el.name] = msg;
  }
  return errors;
}
<form novalidate>
  <label for="age">Age</label>
  <input id="age" name="age" type="number" min="18" required
         data-msg-range-underflow="You must be 18 or over to apply.">
</form>

Step-by-step walkthrough

  1. Declare constraints in HTML. required, type, pattern, min, max, minlength, maxlength and step are free, fast and understood by assistive technology — required also sets the accessible “required” state.
  2. Add novalidate to the form. Evaluation continues; bubbles and submit-blocking stop.
  3. Map ValidityState flags to your own messages. Keep a default table and allow per-field overrides via data-msg-* attributes, so wording stays consistent across browsers and matches the guidance in writing error messages that tell the reader what to do.
  4. Route custom rules through setCustomValidity. Always clear first, and let native failures win so the user fixes the basic format before seeing a business rule.
  5. Skip controls where willValidate is false. That flag already encodes the disabled, readonly and type="hidden" rules from skipping validation for disabled and hidden fields.
  6. Style with :user-invalid, not :invalid. :invalid matches a required empty field on page load; :user-invalid matches only after the user has interacted, which lines up with your own timing rules.
From constraint to rendered message HTML attributes declare constraints. The browser evaluates them continuously into ValidityState, even with novalidate. Custom rules run next and call setCustomValidity only if native checks pass. A message lookup maps the first failing flag to house wording or a per-field override. The custom renderer displays the message, sets aria-invalid and links it with aria-describedby. HTML constraints required, type, pattern, min/max, length. Declarative and free; also exposed to assistive technology. ValidityState Computed by the browser, novalidate or not. el.validity.valueMissing, typeMismatch, … Custom rules setCustomValidity after clearing. Only if native checks pass. Message lookup House wording per flag, data-msg-* overrides. Same voice in every browser and locale. Your renderer aria-invalid, aria-describedby, summary. The browser's bubble never appears.

Failure modes and edge cases

1. Forgetting to clear a custom error

setCustomValidity("x") makes the control invalid until you call setCustomValidity(""). If you set it on blur and never clear it, the field stays invalid after the user fixes it. The helper above clears at the start of every run.

2. badInput on number fields

For type="number", typing “1e” gives value === "" with validity.badInput === true. Check badInput before valueMissing, or you will tell the user a field they typed in is empty. The ordering in ORDER handles this; the broader problem is covered in controlled number inputs and intermediate values.

3. pattern is anchored and uses the v flag

The pattern attribute is matched against the whole value (implicitly ^(?:…)$) and, in current browsers, compiled with the v flag, which makes some characters such as unescaped - inside a class, ( or | in classes a syntax error. An invalid pattern is silently ignored. Test patterns in the browser, not only in a regex tester.

4. Custom elements and shadow DOM

Controls inside a shadow root are not in form.elements unless the custom element is form-associated and reports validity through ElementInternals.setValidity. See validating inputs across shadow DOM boundaries.

5. :user-invalid support

:user-invalid is supported in current Chromium, Firefox and Safari. If you must support older browsers, add a class from your own touched state rather than falling back to :invalid, which lights up untouched required fields on load.

Native flags worth handling explicitly badInput is triggered by unparsable text in a number or date input and should say enter a number rather than required. valueMissing is triggered by an empty required control. typeMismatch is triggered by a malformed email or URL. patternMismatch is triggered by a value not matching the anchored pattern. stepMismatch is triggered by a number between allowed steps, such as 1.5 with step 1. customError is set by setCustomValidity and carries your own message. Flag Triggered by Say badInput "1e" in type=number "Enter a number", not "required" valueMissing empty required control what to enter, not "required" typeMismatch malformed email or URL the expected shape, with an example patternMismatch value not matching the anchored pattern the rule in words stepMismatch 1.5 with step="1" "Use a whole number" customError setCustomValidity(msg) your message, as set

Verification checklist


Frequently Asked Questions

Why keep native validation if I have a schema?

Because it runs for free, is evaluated continuously, exposes required to assistive technology, and gives you badInput, which a schema cannot see — for a number input with unparsable text, the value your schema receives is an empty string. The schema then handles the rules HTML cannot express.

Is reportValidity() ever the right choice?

For small internal tools where native bubbles are acceptable, yes: it is zero code. For product forms, the bubbles cannot be styled, disappear after a few seconds, are inconsistent across browsers and are not reliably announced, so render your own messages.

Does setCustomValidity work on custom elements?

Not directly. A form-associated custom element uses this.internals.setValidity(flags, message, anchor) instead, which feeds the same validity model the form sees. The rest of this page’s approach applies unchanged.


Related

← Form Validation Lifecycle