skip to content

A design system needs inline, styled, consistently worded error messages on every form. How would you decide between keeping the browser's native validation bubbles and switching them off to render your own, and what must you reproduce if you take it over?

level: principalimportance: should knowfreq 33%

answer

  1. free, correct, and unbrandable
  2. the browser speaks its own language
  3. turning off the UI keeps the API
  4. the first thing teams forget to reproduce
  5. one source of rules, two enforcers

basics

~20 s

Native bubbles are free but unstyleable, shown one at a time, dismissed on blur, worded in the browser's language, and absent from the DOM. Taking over means adding novalidate, keeping the constraint attributes, and reproducing the message, the focus move and the announcement yourself.

solid answer

~60 s

I decide on what the native reporting cannot do. Its bubbles cannot be styled or positioned, only one appears at a time, they disappear on blur or after a timeout, their wording comes from the browser's UI language rather than the page's `lang`, and because they are not in the DOM you cannot point a field at one. For an internal tool that is a fine trade — zero code, correct focus behaviour, localized by the browser. For a product design system it usually is not, so I take it over. The key move is that `novalidate` on the `<form>` only disables the browser's automatic *reporting*; the constraint attributes keep working, the `validity` flags keep updating, and `:invalid` keeps matching. So I keep `required`, `pattern` and the rest as the source of truth, call `checkValidity()` in the submit handler, render my own message next to each field, and then reproduce the two things the browser was doing for free: moving focus to the first invalid control, and making the message reachable from the field for assistive technology. And the server validates regardless — none of this is a control.

code

html · 13 lines
html
<form id="signup" action="/signup" method="post" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required
         aria-describedby="email-error">
  <p id="email-error" hidden></p>

  <label for="pin">PIN</label>
  <input id="pin" name="pin" required pattern="[0-9]{4}"
         aria-describedby="pin-error">
  <p id="pin-error" hidden></p>

  <button>Sign up</button>
</form>

go deeper

for a junior

Know that the browser's validation message is its own UI — you cannot style it or place it — and that novalidate turns that reporting off without deleting the constraints.

for a middle

Explain the mechanics of taking over: keep the attributes, add novalidate, call checkValidity in the submit handler, and read validity or validationMessage to build your own message.

for a senior

Name what you inherit — focus to the first invalid control, programmatic association between field and message, a consistent rule for when errors appear — and be able to say why a missing focus move is a real accessibility regression.

for a principal

Frame it as a trade: free correct behaviour versus brand, wording and localization control. Set one house pattern so products never mix the two surfaces, and drive client and server rules from a single definition so they cannot drift.

## What you get for free With no script at all, the browser already: blocks submission, picks the first invalid control, scrolls it into view, focuses it, and shows a message describing the failure in the user's own browser language. That is a complete, accessible, zero-maintenance error flow. Any decision to replace it starts by acknowledging that it is genuinely good. ## What it cannot do Five limits drive nearly every decision to replace it: 1. **No styling.** The bubble is browser chrome. You cannot change its font, colour, shape or position, and it looks different in every engine — an immediate mismatch with a design system. 2. **One at a time.** The browser reports the first failure only. A user fixing a six-field form gets six sequential submit-and-discover rounds instead of a list. 3. **Transient.** The bubble is dismissed on blur or after a short timeout, so the message is gone the moment the user looks away from the field to think. 4. **Wrong language.** The text comes from the browser's UI locale, not from the page's `lang`. A Spanish-language product viewed in an English browser shows English errors it never wrote. 5. **Not in the DOM.** You cannot select it, log it, test it with a DOM query, or reference it from the field. That last one matters most: a design system usually wants each field pointing at its own visible message. A sixth, quieter one: because the constraint text is the browser's, you cannot say anything domain-specific. "Please match the requested format" is not "Card numbers are 16 digits with no spaces." ## The decision Keep the native reporting when the form is short, internal, or a prototype; when localization tracks the browser anyway; when nobody is going to maintain custom error code. Its focus behaviour is correct out of the box and that is worth more than teams assume. Take it over when errors must be styled and inline, when several failures should be visible at once, when copy must be domain-specific and in the page's language, or when errors must persist while the user works. In practice a component library crosses that line almost immediately. What you should not do is mix them — a native bubble popping over your own inline message is worse than either alone. ## Taking it over without throwing away the API The crucial insight is that switching off the UI does not switch off the machinery. `novalidate` on the `<form>` stops the automatic validate-and-report at submit time; it does not stop constraints from being evaluated. `validity`, `checkValidity()`, `validationMessage`, `:invalid` and `:user-invalid` all keep working exactly as before. So the constraint attributes stay in the markup as the single declaration of the rules, and you supply only the presentation: ```html <form novalidate> <label for="email">Email</label> <input id="email" name="email" type="email" required aria-describedby="email-error"> <p id="email-error" hidden></p> <button>Sign up</button> </form> ``` ```js form.addEventListener('submit', (event) => { if (form.checkValidity()) return; // let it submit event.preventDefault(); const bad = [...form.elements].filter((el) => el.willValidate && !el.checkValidity()); bad.forEach(showInlineError); bad[0].focus(); // reproduce what the browser did }); ``` An alternative that keeps the browser's own trigger is to leave `novalidate` off and listen for the `invalid` event on each control, calling `preventDefault()` inside it to suppress that control's bubble while rendering your own. Both are legitimate; the `novalidate` route gives you full control of ordering and of showing all failures at once. ## The obligations you inherit Once you own the reporting you own everything it was doing: - **Focus.** Move focus to the first invalid control on a blocked submit. Skipping this is the single most common regression, and it strands keyboard and screen-reader users who have no idea where the problem is. - **Association.** Each field's message must be programmatically connected to the field, not merely adjacent to it, so it is conveyed when the field receives focus — and the field marked as invalid programmatically at the same moment it is marked visually. - **Timing.** Decide when errors appear and apply it uniformly. Silent until submit and eager afterwards is the usual rule; validating on every keystroke is noise. - **Copy.** You now write and translate every message, including the ones the browser used to generate for free. - **Multiple failures.** If several fields fail, decide whether a summary at the top of the form is warranted for longer forms. ## The line that must be said Whichever way you go, none of it is a security control. Every constraint expressed in markup is advisory — it exists to catch honest mistakes at the point of entry. The server enforces the same rules independently, and the two definitions should be generated from one source where possible so they cannot drift apart into a form that submits values the backend rejects. ## Weak answers "Add `novalidate` and write validation in JavaScript from scratch" throws away the declarative constraints for no reason. "Style the bubble with CSS" is not possible. And an answer that never mentions focus or programmatic association has not actually replaced the native behaviour — only its appearance.

  • Does novalidate stop the constraint attributes from doing anything?
    No — that is the point of using it. It only suppresses the browser's automatic validate-and-report step at submit time. `validity` still updates, `checkValidity()` still returns the right answer, `validationMessage` is still populated and `:invalid` still matches, so the attributes remain the single declaration of the rules while you supply the UI.
  • What is the most commonly forgotten obligation when a team replaces the native bubbles?
    Moving focus to the first invalid control on a blocked submit. The browser did it automatically; a custom renderer that only paints red borders leaves keyboard and screen-reader users with no indication of where the problem is, and on a long form they may not even know submission failed.
  • Is there a middle path that keeps the browser's trigger but your own presentation?
    Yes. Leave `novalidate` off, listen for the `invalid` event on each control, and call `preventDefault()` inside the listener. That suppresses the native bubble for that control while the browser still decides when validation runs, and you render your own message from `validity` or `validationMessage`.
  • How do you keep the client rules and the server rules from drifting apart?
    Generate both from one definition wherever the stack allows it, so a field's constraints render into the markup and compile into the server's checks from the same source. Where that is impossible, treat the server as authoritative and test that anything the markup accepts the server also accepts — a form that submits values the backend rejects is a worse experience than no client validation at all.

saying these in an interview costs you the question

  • The native bubble can be styled with enough CSS
  • novalidate disables the validity API as well
  • Custom errors only need red borders, not focus
  • Client rules make server-side validation redundant
  • Mixing native bubbles with inline messages is fine

context