Rules in the HTML
A form collects what people type and sends it to a server. Validation checks that input first. In HTML you write the rules as attributes, and the browser checks them itself before anything is sent. No JavaScript is needed.
Each rule is an attribute on an input. required: the field must not be empty. type="email": the text must have the shape of an email address. type="number" with min="13": it must be a number, 13 or more.
The browser keeps a validity record for every field: true/false flags, one per kind of problem, plus valid. The empty email field already breaks a rule, so valueMissing is true. The empty age field is fine: it is not required.
Pressing Join
You press Join with nothing typed. Before sending, the browser checks every field. Email is invalid, so the browser stops: nothing is sent. It moves the focus to the first broken field and shows a message there.
You type kim. The record updates as you type: the field is not empty now, so valueMissing turns false. But kim has no @ sign, so it is not an email address: typeMismatch turns true.
You finish it: kim@example.com. That matches the email rule, so typeMismatch turns false. No flag is true now, so valid turns true. The browser checks only the shape: it cannot know if the address really exists.
In the age field you type 9. It is a number, but smaller than min="13", so rangeUnderflow turns true and the field is invalid. Leaving it empty would have been fine, because age is not required.
You press Join. Email passes now, but age does not, so the browser stops the form again and jumps to age. Chrome and Firefox show one message at a time, for the first broken field.
Valid, sent, checked again
You change the age to 15. That is 13 or more, so rangeUnderflow turns false and valid turns true. Now every field in the form is valid.
JavaScript can read the same record: age.validity.rangeUnderflow is false now. form.checkValidity() checks every field and returns true or false. setCustomValidity() adds a rule of your own, such as two passwords that must match.
You press Join again. Every check passes, so the browser sends the form and shows the page the server sends back. With the default method, GET, each name and value goes into the address: /join?email=kim%40example.com&age=15.
The browser's checks help honest people, but anyone can skip them: type /join?email=kim&age=9 straight into the address bar, and no form checks it. So the server must check every value again, and reject this one.
What happens
;; step 1/12 · check-first file signup.html (a form: two fields, one button) 1 <form action="/join"> 2 <label>Email 3 <input name="email" 4 type="email" required> 5 </label> 6 <label>Age 7 <input name="age" 8 type="number" min="13"> 9 </label> 10 <button>Join</button> 11 </form> rules attributes on the inputs; the browser checks them
The validity record changes the moment the value changes, but the browser only shows a message when the form is submitted (or when JavaScript calls reportValidity()). CSS can style broken fields as you type with the :invalid pseudo-class, or only after the person has interacted with them with :user-invalid. The words in the message bubbles are Chrome's. The HTML standard says the browser must report the problem, not which words to use, so Firefox and Safari word them differently.