HTML & CSS NotesRohit’s interview study guide
Chapter 02

Forms

What a form actually sends, labels and accessible names, input types, the browser's built-in validation and its JavaScript API, styling valid and invalid fields, buttons, fieldsets, and select, datalist and textarea.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#How a form submits

Added

A <form> is the web's original API client. When it's submitted, the browser walks its controls, collects a name/value pair for every control that's eligible, encodes the list, and navigates to the form's action URL with it: as a query string for method="get", or in the request body for method="post". No JavaScript is involved, which is why plain forms work before your scripts load, with scripts blocked, and in every browser.

Which controls get sent is the part that trips people up. A control is included only if it has a name, isn't disabled, and (for checkboxes and radios) is checked. A checkbox with no value attribute sends the value on. A submit button is included only if it's the one that submitted the form. Read-only fields are sent; disabled fields are not. The order is the order of the controls in the document.

Mental model: the form is a courier filling in a delivery slip. It writes one line per labelled parcel (name=value), skips anything without a label (no name), anything taped shut (disabled) and any empty checkbox, and the method decides whether the slip is stuck on the outside of the envelope (GET, visible in the URL) or sealed inside (POST, in the body).

What a GET search form sends
CSS
form { display: grid; gap: 6px; max-width: 360px; padding: 10px; border: 1px solid #c7cfe0; border-radius: 8px; }
label { display: flex; gap: 6px; align-items: center; }
input:not([type=checkbox]), select { font: inherit; padding: 3px 6px; flex: 1; }
.off { color: #6b7280; }
HTML
<form id="search" action="/search" method="get">
  <label>Search <input name="q" value="red shoes & socks"></label>
  <label><input type="checkbox" name="in_stock" checked> In stock only</label>
  <label><input type="checkbox" name="sale"> On sale</label>
  <label>Sort
    <select name="sort">
      <option value="price">Price</option>
      <option value="new" selected>Newest</option>
    </select>
  </label>
  <input type="hidden" name="ref" value="nav">
  <label class="off">No name: <input value="never sent"></label>
  <label class="off">Disabled: <input name="coupon" value="SAVE10" disabled></label>
  <button name="view" value="grid">Search</button>
  <button name="view" value="list">Search (list view)</button>
</form>
Resultlive, in your browser
What’s happening
  1. The test presses the first "Search" button and catches the navigation the form starts (with the Navigation API, then cancels it). The browser navigates to /search?q=red+shoes+%26+socks&in_stock=on&sort=new&ref=nav&view=grid. The live result on this page is sandboxed, so pressing the button there does nothing; the test shows what would be sent.
  2. q is encoded as application/x-www-form-urlencoded: spaces become +, and & becomes %26 so it can't be confused with the separator between pairs. The checked checkbox has no value, so it sends in_stock=on; the unchecked sale sends nothing at all (not sale=off), so the server must treat a missing key as "false".
  3. The select sends its selected option's value (new, not the text "Newest"). The hidden input is sent like any other (ref=nav); hidden doesn't mean secret, it's in the URL for anyone to see.
  4. The input with no name and the disabled coupon are both missing. new FormData(form) gives the same list the browser uses, which is why it's the standard way to read a form in JavaScript.
  5. The submit button is included only as the submitter: view=grid when the first button is pressed, and new FormData(form, submitter) with the second button gives view=list. Two buttons with the same name and different values are a neat way to offer two actions in one form.
AdvancedGET vs POST
method="get" (the default)method="post"
Where the data goesquery string of the URLrequest body
Visible in URL, history, logsyesno
Bookmarkable, shareable, cacheableyesno
Sizelimited by URL length (servers commonly cap at a few KB)large; files possible
Meaning"read something" (search, filters), safe to repeat"change something" (sign in, buy, post), not safe to repeat
Back / refreshreloads the URLbrowser warns about resubmitting

Use GET for searches and filters, so the result has a URL you can share. Use POST for anything that changes state or contains sensitive data; a password in a GET URL ends up in the browser history and server logs. After a successful POST, servers usually reply with a redirect (the Post/Redirect/Get pattern) so refreshing doesn't submit twice.

POST, enctype and invalid attribute values
CSS
form { margin-bottom: 8px; padding: 8px; border: 1px solid #c7cfe0; border-radius: 8px; }
HTML
<form id="login" action="/session" method="post">
  <input name="email" value="rohit@example.com"> <input type="password" name="password" value="open sesame"> <button>Sign in</button>
</form>
<form id="upload" action="/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="avatar"> <button>Upload</button>
</form>
<form id="odd" action="/x" method="put" enctype="application/json"></form>
Resultlive, in your browser
What’s happening
  1. Submitting the login form navigates to /session with no query string: with POST the pairs go in the request body. The body is the same urlencoded format as a GET query, email=rohit%40example.com&password=open+sesame (@ is encoded as %40). It's hidden from the URL, not encrypted; only HTTPS protects it in transit.
  2. enctype chooses the body format. The default is application/x-www-form-urlencoded. Files need multipart/form-data, which sends each field as its own part with headers, so binary data can travel unencoded. With the default enctype a file input only sends the file's name, which is the classic "my upload is empty on the server" bug.
  3. In FormData, a file input is a File object. With nothing chosen, it's still an entry: a File with an empty name and size 0, so servers should check the size, not just the key.
  4. HTML forms only know get, post and dialog. method="put" is invalid, so form.method falls back to "get", and enctype="application/json" falls back to the default too. To send PUT, DELETE or JSON you need JavaScript (fetch), or a framework convention like a hidden _method field.
AdvancedSubmitting from JavaScript: requestSubmit() vs submit()
JavaScript
form.addEventListener("submit", async (event) => {
  event.preventDefault();                       // stop the navigation
  const data = new FormData(form, event.submitter);
  await fetch(form.action, { method: "POST", body: data }); // multipart body
});

form.requestSubmit();   // like clicking a submit button: validates, fires "submit"
form.submit();          // skips validation AND the submit event, navigates directly
requestSubmit() and submit() side by side
HTML
<form id="f" action="/order">
  <input name="email" required placeholder="email (required, left empty)">
</form>
Resultlive, in your browser
What’s happening
  1. The form has one required field, left empty. requestSubmit() behaves exactly like a user pressing a submit button: it runs validation, finds the empty field, fires invalid on it, and stops. No submit event, no navigation.
  2. submit() is the old method: it skips validation and never fires the submit event, so your submit listener (and any preventDefault() in it) is bypassed. The test sees no new events, yet the browser navigates to /order with the invalid data.
  3. That's why requestSubmit() (Baseline since 2022) is the one to use when code needs to submit a form, for example on a keyboard shortcut. In a submit handler, event.submitter tells you which button was pressed, and new FormData(form, event.submitter) includes it.
  4. If you fetch a FormData object as the body, the browser sends it as multipart/form-data and sets the boundary header itself; don't set Content-Type manually. Use new URLSearchParams(formData) as the body if the server expects urlencoded.
What does the server receive?
HTML
<form action="/prefs" method="get">
  <input type="checkbox" name="email_updates" value="yes">
  <input type="checkbox" name="topics" value="css" checked>
  <input type="checkbox" name="topics" value="html" checked>
  <input name="nickname" value="Ro" readonly>
  <input name="plan" value="pro" disabled>
  <button>Save</button>
</form>
Show answer

/prefs?topics=css&topics=html&nickname=Ro

What’s happening
  1. email_updates is unchecked, so it's left out entirely; the server never sees the key.
  2. Both topics checkboxes are checked, so the key appears twice: topics=css&topics=html. Servers differ in how they read repeated keys (PHP needs the name topics[]; Express with qs gives an array; URLSearchParams.get returns only the first, getAll returns both).
  3. nickname is readonly, which only stops editing: it's still submitted. plan is disabled, so it's excluded. If you want a field shown but not editable and sent, use readonly.
  4. The button has no name, so it adds nothing. This is the same rule set the demo above measured: name required, not disabled, checked if checkable.

#Labels and accessible names for inputs

Added

Every form control needs an accessible name: the text a screen reader announces when the control gets focus ("Email, edit text"). Without one, a blind user hears "edit text" and has to guess. The right way to give a name is a <label>, either pointing at the control with for="id" or wrapping it. A label also makes the text clickable: clicking it focuses the input or toggles the checkbox, which turns a tiny checkbox into a big target for mouse and touch users.

A placeholder is not a label. It disappears as soon as the user types, so they can't check what the field was for; its default grey text usually fails contrast requirements; and it's announced inconsistently by assistive tech. Use it, if at all, for an example of the format ("e.g. 555 123 4567"), with a real label as well. When there's genuinely no room for visible text (a search field next to a magnifier button), use aria-label or aria-labelledby; those give a name but no click target.

Mental model: the label is the name tag sewn onto the field: it's always visible, belongs to exactly one field, and grabbing it (clicking) grabs the field. A placeholder is a sticky note on the field that falls off as soon as you start writing.

Ways to name an input
CSS
.row { display: flex; gap: 8px; align-items: center; margin: 5px 0; }
.row > :first-child { width: 120px; font-size: 13px; color: #4b5563; }
input:not([type=checkbox]) { font: inherit; padding: 3px 6px; width: 200px; }
HTML
<div class="row"><span>for / id</span><label for="email">Email</label> <input id="email" type="email"></div>
<div class="row"><span>wrapping</span><label>Full name <input id="name"></label></div>
<div class="row"><span>placeholder only</span><input id="phone" placeholder="Phone"></div>
<div class="row"><span>aria-label</span><input id="q" type="search" aria-label="Search the store"></div>
<div class="row"><span>aria-labelledby</span><span id="card-label">Card number</span><input id="card" aria-labelledby="card-label"></div>
<div class="row"><span>two labels</span><label for="street">Street</label><input id="street"><label for="street">(line 1)</label></div>
<div class="row"><span>checkbox</span><input type="checkbox" id="terms"><label for="terms">I accept the terms</label></div>
Resultlive, in your browser
What’s happening
  1. <label for="email"> names the input with id="email": computedName is "Email". Wrapping works the same without ids: <label>Full name <input></label> gives "Full name". Both are fine; for/id is more robust when the label and input sit in different layout containers, and wrapping saves inventing ids.
  2. The placeholder-only field is named "Phone": Chrome falls back to the placeholder when nothing else exists. But once the user types (555), :placeholder-shown stops matching and the hint is gone from the screen, so a sighted user can no longer see what the field was. Don't rely on the fallback.
  3. aria-label="Search the store" and aria-labelledby="card-label" both name their fields ("Search the store", "Card number"). Notice the search input's role is searchbox because of type="search". But clicking the aria-labelledby text does nothing: focus stays where it was (on the terms checkbox, clicked just before). Only a real <label> is a click target.
  4. A control can have several labels: input.labels has 2 entries, and the name joins them in DOM order: "Street (line 1)". Handy for a visible label plus a unit after the field.
  5. Clicking "I accept the terms" toggles the checkbox (checked becomes true), and clicking "Email" focuses the email field. Try it in the live result: the label text is the click target. This is the hit-area benefit WCAG's target-size guidance is about.
AdvancedLabels that don't work
AdvancedHints and errors: aria-describedby
What's wrong with this field?
HTML
<div class="field">
  <span class="field-label">Email</span>
  <input type="email" name="email" placeholder="Email">
</div>
Show answer

The visible "Email" text isn't associated with the input, so the field's name comes only from the placeholder, and clicking "Email" doesn't focus it.

What’s happening
  1. A span next to an input is just text; the browser has no idea it describes the input.
  2. With nothing else, Chrome names the field from its placeholder, "Email", which is why automated checks may not catch it. But the placeholder vanishes once the user types, and other browser and screen-reader combinations don't all use it.
  3. Clicking the visible "Email" text does nothing, so the click target is only the input itself.
  4. Fix: make the span a <label for="email"> and give the input id="email" (or wrap the input in the label), and drop the redundant placeholder or use it for an example like "name@example.com".

#Input types and the attributes that matter

Added

The type attribute changes three things at once: the keyboard and picker the user gets (an email keyboard with @, a numeric keypad, a native date picker), the value's rules (what the browser accepts, how it normalises it, what counts as a type mismatch), and the role assistive tech announces (textbox, spinbutton, slider, checkbox). An unknown type falls back to text, so new types degrade safely in old browsers.

The other attributes that matter in interviews are about helping the user fill the form quickly: autocomplete tells the browser and password manager what the field is (email, name, street-address, cc-number, one-time-code, new-password, current-password), which makes autofill work and is a WCAG requirement for fields about the user. inputmode picks a virtual keyboard without changing validation (numeric, decimal, tel, email, url, search), and enterkeyhint labels the Enter key ("Go", "Next", "Send").

Mental model: type is the kind of form field printed on paper (a date box, a tick box, a phone number box) that also comes with its own rules; inputmode is just which pen you hand the user, and autocomplete is a sticky note telling the browser's assistant what goes there.

What the type changes
CSS
.grid { display: grid; grid-template-columns: 110px 220px; gap: 6px 10px; align-items: center; font-size: 14px; }
.grid input:not([type=checkbox]):not([type=radio]) { font: inherit; padding: 2px 6px; }
HTML
<div class="grid">
  <label for="em">email</label><input id="em" type="email" value="  rohit@example.com  ">
  <label for="em2">email</label><input id="em2" type="email" value="rohit@localhost">
  <label for="num">number</label><input id="num" type="number" value="1e3">
  <label for="bad">number</label><input id="bad" type="number" value="12abc">
  <label for="date">date</label><input id="date" type="date" value="2026-10-20">
  <label for="baddate">date</label><input id="baddate" type="date" value="20/10/2026">
  <label for="tel">tel</label><input id="tel" type="tel" value="call me maybe">
  <label for="url">url</label><input id="url" type="url" value="example.com">
  <label for="range">range</label><input id="range" type="range" min="0" max="10" value="7">
  <label for="mystery">type="phone"</label><input id="mystery" type="phone">
  <label for="pin">inputmode</label><input id="pin" inputmode="numeric" autocomplete="one-time-code" value="abc">
</div>
Resultlive, in your browser
What’s happening
  1. email: the value " rohit@example.com " is sanitised to "rohit@example.com" (surrounding spaces removed). The format check is loose: rohit@localhost is valid, because an email needs no dot in the domain. Real validation (does the mailbox exist?) is the server's job, usually by sending a confirmation email.
  2. number: 1e3 is accepted and valueAsNumber gives 1000. "12abc" isn't a valid number, so the value is sanitised to "". The same happens when a user types garbage: value is "" and validity.badInput is true, so you can't tell "empty" from "invalid" by the value alone. value is always a string; use valueAsNumber for a number (it's NaN when empty).
  3. date: the value is always yyyy-mm-dd ("2026-10-20") whatever date format the picker shows in the user's locale. valueAsNumber is midnight UTC of that day. Anything else, like "20/10/2026", is dropped to "".
  4. tel has no format rules at all ("call me maybe" is valid), because phone formats differ by country; it exists to show a phone keypad. url requires a scheme: example.com is a typeMismatch, https://example.com would pass. range is a slider whose value is still the string "7".
  5. type="phone" isn't a real type, so .type reports "text" (the attribute still says "phone"). And inputmode="numeric" only changes the keyboard: "abc" is still valid. If you want digits only, add pattern="[0-9]*" or validate yourself. The roles differ too: number is a spinbutton, range a slider, the text-like types textbox.
AdvancedCheckboxes and radio buttons

Radio buttons with the same name form a group: checking one unchecks the others, and arrow keys move between them while Tab moves into and out of the group as a single stop. Checkboxes are independent, even with the same name; each checked one is sent as its own pair. Always wrap the group in a fieldset with a legend so the question is announced (see fieldset and legend).

Radio groups and the indeterminate checkbox
CSS
label { display: block; margin: 3px 0; }
HTML
<form id="f">
  <label><input type="radio" name="size" value="s"> Small</label>
  <label><input type="radio" name="size" value="m" checked> Medium</label>
  <label><input type="radio" name="size" value="l"> Large</label>
  <label><input type="radio" name="colour" value="red" checked> Red (another group)</label>
  <label><input type="checkbox" name="all" id="all"> Select all</label>
</form>
Resultlive, in your browser
What’s happening
  1. The three size radios start with Medium checked. Clicking Large checks it and automatically unchecks Medium: [false, false, true]. That's the group behaviour, and it comes purely from the shared name.
  2. The colour radio has a different name, so it's a separate group and stays checked.
  3. form.elements.size returns a RadioNodeList for the group, and its value is the checked radio's value: "l". That's the easiest way to read a radio group in JavaScript.
  4. indeterminate (the "some selected" dash on a "Select all" box) can only be set from JavaScript. It's purely visual: checked is still false, it matches :indeterminate, and nothing is submitted for it.
Advancedautocomplete values worth knowing
FieldautocompleteNotes
Sign-in emailusername or emailpassword managers look for username
Sign-in passwordcurrent-password
Sign-up / change passwordnew-passwordlets the browser suggest a strong password
SMS codeone-time-codelets phones offer the code from the message
Full namename (given-name, family-name)
Addressstreet-address, address-line1, postal-code, country
Cardcc-name, cc-number, cc-exp, cc-csc
Turning it offofffor genuinely non-personal fields (a search box with its own suggestions); browsers may ignore it for login fields

#Built-in validation

Added

Browsers can validate forms with no JavaScript: you declare constraints as attributes and the browser checks them on submit, blocks submission if any fail, focuses the first invalid field and shows a message. The constraints: required, minlength/maxlength (text length), min/max/step (numbers, dates, ranges), pattern (a regular expression the whole value must match), and the type itself (email, url). Each failure sets a flag on the control's validity object, which is also what the CSS pseudo-classes :valid and :invalid read.

Two rules make built-in validation predictable. A control is only validated if it's a candidate: disabled, read-only and hidden inputs are "barred from constraint validation", so they never block a submit. And novalidate on the form (or formnovalidate on a particular button, typical for "Save draft") turns the submit-time check off, while the constraints still work for CSS and JavaScript.

Mental model: each field carries a checklist (validity) that the browser keeps up to date as the value changes. On submit, a doorman reads every checklist and won't let the form through while any box is ticked red. Client-side validation is for the user's convenience; the server must validate again, because anyone can bypass the doorman.

Constraints and the validity flags they set
CSS
.grid { display: grid; grid-template-columns: 170px 1fr; gap: 5px 10px; font-size: 13px; align-items: center; }
input { font: inherit; padding: 2px 6px; }
input:invalid { border: 2px solid #dc2626; }
HTML
<form id="f" class="grid">
  <label for="req">required, empty</label><input id="req" required>
  <label for="em">type=email</label><input id="em" type="email" value="rohit">
  <label for="url">type=url</label><input id="url" type="url" value="example.com">
  <label for="pat">pattern="[0-9]{3}"</label><input id="pat" pattern="[0-9]{3}" value="1234">
  <label for="max">max="10"</label><input id="max" type="number" max="10" value="12">
  <label for="step">min="1" step="2"</label><input id="step" type="number" min="1" step="2" value="4">
  <label for="minl">minlength="5"</label><input id="minl" minlength="5" value="ab">
  <label for="ok">no constraints</label><input id="ok" value="anything">
  <label for="dis">disabled + required</label><input id="dis" required disabled>
  <label for="ro">readonly + required</label><input id="ro" required readonly>
</form>
Resultlive, in your browser
What’s happening
  1. Each constraint sets one flag on validity: the empty required field has valueMissing, "rohit" in an email field and "example.com" in a url field have typeMismatch, "1234" against pattern="[0-9]{3}" has patternMismatch, and 12 against max="10" has rangeOverflow. Every invalid field gets the red border from input:invalid, straight away, before the user has done anything (the next topics deal with that).
  2. pattern must match the entire value: the browser compiles it as ^(?:[0-9]{3})$, so "1234" fails even though it contains three digits. You never write ^ or $ yourself.
  3. step="2" with min="1" allows 1, 3, 5…: steps count from min, not from 0, so 4 has stepMismatch, and Chrome's message even names the nearest valid values, 3 and 5. Use step="any" to allow any decimal in a number field (the default step is 1, so 1.5 is invalid otherwise).
  4. minlength="5" with value="ab" is valid: length constraints only apply to values the user typed, not to values set in markup or by script. That's deliberate (a pre-filled value shouldn't block a form the user never touched), but it surprises people writing tests.
  5. The disabled and the readonly required fields are empty, yet both have willValidate false and don't match :invalid: they're barred from validation, because the user can't fix them. The whole form fails checkValidity() and matches :invalid because some of its fields do. The messages are Chrome's English strings; other browsers and languages word them differently, so never assert on them in cross-browser tests.
Advancedpattern uses the v flag

Since 2023, browsers compile pattern with the regular expression v flag (Unicode sets mode), which is stricter than the old u flag about special characters inside character classes. A pattern that is invalid under v is ignored: the field silently stops being checked, and Chrome only logs a console error. The common casualty is an unescaped - in a class.

An invalid pattern is ignored
HTML
<input id="old" pattern="[\w-]+" value="has spaces">
<input id="fixed" pattern="[\w\-]+" value="has spaces">
<input id="alt" pattern="[a-z]+|[0-9]+" value="abc1">
Resultlive, in your browser
What’s happening
  1. [\w-]+ is meant to allow letters, digits, underscores and dashes. Under the v flag, a bare - after a class escape inside [...] is a syntax error, so Chrome drops the pattern: "has spaces" shows no patternMismatch. The field looks validated but isn't.
  2. Escaping the dash, [\w\-]+, makes it valid again, and "has spaces" is correctly flagged.
  3. [a-z]+|[0-9]+ against "abc1" fails, because the whole alternation is wrapped as ^(?:[a-z]+|[0-9]+)$: the value must be all letters or all digits. Test every pattern with a valid and an invalid value, and watch the console for a pattern error.
Which fields block the submit?
HTML
<form>
  <input name="a" type="email" value="ro@hit">
  <input name="b" type="number" min="0" max="100" step="10" value="35">
  <input name="c" pattern="[A-Z]{2}" value="ca">
  <input name="d" required disabled>
  <input name="e" type="text" maxlength="3" value="abcdef">
</form>
Show answer

b and c block it. a, d and e don't.

What’s happening
  1. a: ro@hit is a valid email for the browser; a dot in the domain isn't required.
  2. b: steps of 10 from min="0" allow 0, 10, 20, 30, 40… so 35 has stepMismatch. Blocks.
  3. c: patterns are case-sensitive and anchored, so ca doesn't match [A-Z]{2}. Blocks.
  4. d: disabled controls are barred from validation, so required is ignored (and it isn't submitted either).
  5. e: maxlength stops the user typing more than 3 characters, but a longer value set in markup isn't flagged (tooLong is only set for user edits, like minlength in the demo). It's submitted as abcdef, which is one more reason the server must validate.

#The constraint validation API

Added

The same validation is exposed to JavaScript, so you can check it, extend it with your own rules, and show messages your way. The pieces: el.validity (a ValidityState with one boolean per failure kind plus valid), el.validationMessage (the browser's message), el.willValidate (is it a candidate at all), checkValidity() (returns a boolean and fires invalid events on failing controls), reportValidity() (the same, plus showing the browser's message bubble and focusing the field), and setCustomValidity(message) (adds your own failure: any non-empty string makes the field invalid, an empty string clears it).

That last one is how you add rules the attributes can't express: "passwords must match", "this username is taken", "end date after start date". Your custom error becomes the customError flag, so :invalid, checkValidity(), the submit-time check and validationMessage all treat it like a built-in one.

Mental model: validity is the checklist, checkValidity() asks "would the doorman let this through?" quietly, reportValidity() asks the doorman to shout about it, and setCustomValidity() is you writing your own item on the checklist, which stays there until you erase it.

A custom rule with setCustomValidity
CSS
form { display: grid; grid-template-columns: 130px 200px; gap: 6px 10px; align-items: center; }
input { font: inherit; padding: 2px 6px; }
input:invalid { border: 2px solid #dc2626; }
input:valid { border: 2px solid #16a34a; }
HTML
<form id="signup">
  <label for="password">Password</label><input id="password" name="password" type="password" minlength="12" required>
  <label for="confirm">Confirm</label><input id="confirm" name="confirm" type="password" required>
</form>
signup.js
const form = document.querySelector("#signup");
const password = form.elements.password;
const confirm = form.elements.confirm;

function checkMatch() {
  confirm.setCustomValidity(confirm.value === password.value ? "" : "Passwords don't match.");
}
password.addEventListener("input", checkMatch);
confirm.addEventListener("input", checkMatch);

form.addEventListener("submit", (event) => {
  if (!form.reportValidity()) event.preventDefault(); // only matters with novalidate
});
Resultlive, in your browser
What’s happening
  1. The test runs the exact checkMatch from signup.js. With "correct-horse" and "correct-hose", it calls setCustomValidity("Passwords don't match.") on the confirm field: customError is true, valid is false and validationMessage is your string. (The test calls checkMatch() directly because it can't type; in the page the input listeners call it on every keystroke.)
  2. CSS treats the custom error like any other: the confirm field matches :invalid and gets the red border (rgb(220, 38, 38)), the password field matches :valid and is green (rgb(22, 163, 74)).
  3. form.checkValidity() returns false and fires an invalid event on the failing field. The invalid event doesn't bubble: a listener on the form only sees it in the capture phase (invalids is ["confirm"], the bubbling listener counted 0). To handle all fields in one place, pass true as the third argument.
  4. Once the values match, checkMatch() sets the message to "", which clears the custom error, and the whole form is valid. reportValidity() returns true now; when it returns false it also shows the browser's bubble on the first invalid field.
  5. The gotcha: a custom error never clears itself. After setting a message, changing the value to anything still leaves customError true, and the form can never be submitted. Every code path that sets a message needs a matching setCustomValidity(""), which is why the check runs on every input event.
AdvancedValidityState at a glance
FlagSet byExample
valueMissingrequiredempty required field, required checkbox unchecked, no radio in a required group
typeMismatchtype="email", type="url"rohit in an email field
patternMismatchpattern1234 against [0-9]{3}
tooShort / tooLongminlength / maxlengthonly after the user edits the value
rangeUnderflow / rangeOverflowmin / max12 with max="10"
stepMismatchstep (counted from min)4 with min="1" step="2"
badInputthe browser can't parse what was typedletters typed into a number field
customErrorsetCustomValidity("…")"Passwords don't match."
validnone of the above
What does this log?
JavaScript
const input = document.createElement("input");
input.required = true;
console.log(input.checkValidity());
input.value = "x";
input.setCustomValidity("Taken");
console.log(input.checkValidity(), input.validationMessage);
input.value = "y";
console.log(input.validity.valid);
input.setCustomValidity("");
console.log(input.validity.valid);
Show answer

false, then false "Taken", then false, then true.

What’s happening
  1. A required input with no value has valueMissing, so checkValidity() is false (and an invalid event fires on it). It doesn't need to be in a form or in the document.
  2. Setting the value fixes valueMissing, but setCustomValidity("Taken") adds customError, so it's still false, and validationMessage is "Taken".
  3. Changing the value to "y" doesn't clear the custom error; only another setCustomValidity call does. Still false.
  4. setCustomValidity("") removes it, and with a value present, valid is true.

#Styling valid and invalid fields: :invalid vs :user-invalid

Added

:invalid and :valid match a control according to its validity, all the time. That's correct, but it makes a bad experience: a form with required fields shows every one of them red the moment the page loads, before the user has typed anything, and an email field turns red after the first letter. Users get told off for mistakes they haven't made yet.

:user-invalid and :user-valid fix that. They match only after the user has interacted with the field (changed it and moved on) or tried to submit the form. Before that, a pristine field matches neither. They're Baseline 2023 (Chrome 119, Firefox 88, Safari 16.5) and widely available since May 2026 per MDN, so for most audiences you can use them directly. Before them, people used a JavaScript "touched" class or the :not(:placeholder-shown) trick.

Mental model: :invalid is a teacher marking your homework while you're still writing it. :user-invalid waits until you hand it in (or at least put your pen down on that question).

:invalid vs :user-invalid on the same field
CSS
form { display: grid; grid-template-columns: 150px 200px; gap: 6px 10px; align-items: center; }
input { font: inherit; padding: 2px 6px; border: 2px solid #9ca3af; border-radius: 4px; }
#eager:invalid { border-color: #dc2626; }
#polite:user-invalid { border-color: #dc2626; }
#polite:user-valid { border-color: #16a34a; }
label:has(+ input:user-invalid) { color: #dc2626; }
HTML
<form id="f">
  <label for="eager">:invalid</label><input id="eager" type="email" required>
  <label for="polite">:user-invalid</label><input id="polite" type="email" required>
  <label for="name">Name</label><input id="name" required>
  <button id="go">Submit</button>
</form>
Resultlive, in your browser
What’s happening
  1. On load, both email fields are empty and required, so both match :invalid. The first is styled with :invalid and is already red (rgb(220, 38, 38)); the second is styled with :user-invalid and stays grey (rgb(156, 163, 175)), because the user hasn't touched it.
  2. The test sets the second field's value to rohit@example.com from script. It's now valid, but it still matches neither :user-valid nor :user-invalid and stays grey: a script changing the value isn't user interaction. When a real user changes the value and moves on (MDN describes it as the value being "committed", for example by leaving the field), the field starts matching. In the live result, click into the second field, type "ro" and click elsewhere: it turns red only then.
  3. A submit attempt counts as interaction for every field: after requestSubmit(), the untouched, empty Name field matches :user-invalid, and the filled email field matches :user-valid and turns green (rgb(22, 163, 74)). That's exactly the behaviour you want: no red on load, every problem shown on submit. The browser also focused the first invalid field, #eager.
  4. label:has(+ input:user-invalid) colours the Name label red too, with no JavaScript. :has() makes it easy to style a whole field group (label, hint, icon) from the input's state.
  5. The live result can't show step 3: frames on this page are sandboxed without form submission, and in a sandboxed frame pressing Submit doesn't even run validation. On a real page, pressing Submit there would turn the empty fields red.
AdvancedThe fallback before :user-invalid
CSS
/* Only flag fields that have a value: an empty field shows its placeholder. */
input:not(:placeholder-shown):invalid { border-color: #dc2626; }
The :placeholder-shown trick
CSS
input { font: inherit; padding: 2px 6px; border: 2px solid #9ca3af; }
input:not(:placeholder-shown):invalid { border-color: #dc2626; }
HTML
<input id="empty" type="email" placeholder=" " required>
<input id="typed" type="email" placeholder=" " value="rohit">
Resultlive, in your browser
What’s happening
  1. :placeholder-shown matches while the field is empty and showing its placeholder, so :not(:placeholder-shown):invalid only flags fields that have a value. The field needs a placeholder for this to work; a single space (placeholder=" ") is enough.
  2. The empty required field isn't red, even though it's invalid. The field with "rohit" is red: it has a value and it's a typeMismatch.
  3. The weakness: the empty required field is never flagged, not even on submit, and a half-typed email turns red while the user is still typing. That's why :user-invalid is the better tool now that it's widely available.

#Buttons in forms: submit, reset and button

Added

A <button> has three types: submit (submits its form), reset (restores every control to its initial value) and button (does nothing by itself; you attach behaviour). The trap: the default type is submit. A <button> with no type inside a form submits the form when clicked, so a "Show password" or "Add item" button with no type reloads the page and loses the user's input. Writing type="button" on every non-submit button is the habit that avoids it.

Buttons can also override the form for one click: formaction, formmethod, formenctype, formnovalidate and formtarget replace the form's own action, method, enctype, novalidate and target. And the form attribute associates a control with a form by id, so a submit button in a sticky footer or a dialog can belong to a form elsewhere on the page.

Mental model: inside a form, a bare <button> is a doorbell wired to the front door: press it and the form goes. type="button" unwires it so you can use it for something else; formaction rewires it to a different door for that one press.

The default type and per-button overrides
CSS
form { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 8px; border: 1px solid #c7cfe0; border-radius: 8px; }
button { font: inherit; padding: 3px 10px; }
input { font: inherit; padding: 2px 6px; width: 140px; }
HTML
<form id="post" action="/posts" method="post">
  <input name="title" value="Draft title" required>
  <button id="plain">Publish</button>
  <button id="toggle" type="button">Preview</button>
  <button id="draft" formaction="/drafts" formnovalidate>Save draft</button>
  <button id="reset" type="reset">Reset</button>
</form>
<button id="outside" form="post">Publish (outside the form)</button>
<button id="lonely">Not in any form</button>
Resultlive, in your browser
What’s happening
  1. Four of the six buttons report type "submit", including "Publish" and "Not in any form", which have no type attribute at all. Only type="button" and type="reset" change it.
  2. Clicking "Publish" (no type) submits to /posts. "Preview" (type="button") doesn't navigate; it's inert until a script gives it a job. This is the bug to spot in code review: a bare <button> for a UI action inside a form.
  3. "Publish (outside the form)" sits outside the <form> element, but form="post" associates it: its .form is the form with id post, and clicking it submits to /posts. "Not in any form" is still type submit, but with no form owner (.form is null), clicking it does nothing.
  4. With the title emptied, "Publish" is blocked by required (no navigation). "Save draft" has formaction="/drafts" and formnovalidate, so it skips validation and submits to /drafts instead, the classic draft/publish pair in one form.
  5. "Reset" puts every control back to its initial value, the value attribute: "Edited" becomes "Draft title". Reset buttons are rarely a good idea (one mis-click wipes the form), and they don't reset to the last saved state, only to the page's markup.
AdvancedEnter submits the form

Pressing Enter in a text field submits the form ("implicit submission"), and the browser treats the form's first submit button as the submitter, so its name/value are sent and its formaction applies. If the first submit button in source order is "Delete", Enter deletes. Put the primary action first in the markup (you can reorder visually with CSS), and if a form has no submit button at all, Enter still submits it when it has only one single-line text field. The demo tests can't press real keys, so this was checked separately in Chrome 155 with real key events on the demo above: Enter in the title field submitted the form to /posts, the first submit button's destination. (In the live result on this page nothing happens, because its frame isn't allowed to submit forms.)

Why does the page reload?
HTML
<form id="cart">
  <ul id="items"></ul>
  <button onclick="addItem()">Add item</button>
  <button type="submit">Checkout</button>
</form>

Clicking "Add item" adds an li and then the page reloads, and the item is gone. Why?

Show answer

"Add item" has no type, so it's a submit button: after the click handler runs, the form submits and the page navigates.

What’s happening
  1. The click runs addItem(), which adds the li.
  2. Then the button's default action runs: a <button> defaults to type="submit", so it submits #cart.
  3. The form has no action, so it submits to the current URL with GET, which reloads the page and loses the DOM change.
  4. Fix: <button type="button" onclick="addItem()">. Calling event.preventDefault() in the handler also works, but the type makes the intent obvious.

#Grouping controls: fieldset and legend

Added

A <fieldset> groups related controls, and its first child <legend> is the group's caption. The group's legend becomes its accessible name, so when a screen-reader user moves into the group they hear the question as well as the option: "Delivery speed, group. Express, radio button, 2 of 3". Without it, a radio button labelled "Express" makes no sense on its own. That's why every radio group and every set of related checkboxes should be in a fieldset.

A fieldset has a second, very practical feature: disabled on a fieldset disables every control inside it (except those inside its first legend). Disabled controls can't be focused, aren't validated and aren't submitted, which makes it a one-attribute way to switch off a whole section, such as a billing address when "same as shipping" is ticked.

Mental model: a fieldset is a labelled box on a paper form, "Delivery speed: ○ Standard ○ Express ○ Next day", where the box's title is part of every question inside it. Disabling the fieldset is taping the whole box shut.

A radio group, and disabling a whole section
CSS
fieldset { border: 1px solid #c7cfe0; border-radius: 8px; padding: 6px 12px 10px; margin: 0 0 8px; }
legend { font-weight: 600; padding: 0 4px; }
label { display: block; margin: 2px 0; }
fieldset:disabled { opacity: 0.55; }
input[type=text] { font: inherit; padding: 2px 6px; }
HTML
<form id="checkout">
  <fieldset id="speed">
    <legend>Delivery speed</legend>
    <label><input type="radio" name="speed" value="standard" required> Standard</label>
    <label><input type="radio" name="speed" value="express"> Express</label>
    <label><input type="radio" name="speed" value="next-day"> Next day</label>
  </fieldset>
  <fieldset id="billing" disabled>
    <legend><label><input type="checkbox" id="different" name="different"> Different billing address</label></legend>
    <label>Street <input type="text" id="street" name="street" required></label>
    <label>City <input type="text" id="city" name="city" value="Toronto"></label>
  </fieldset>
</form>
Resultlive, in your browser
What’s happening
  1. The first fieldset is a group named "Delivery speed", taken from its legend. Screen readers announce that name when focus enters the group, so "Express" is heard as an answer to a question.
  2. required on one radio makes the whole group required: with none checked, every radio has valueMissing. Clicking Express fixes it for all three. You only need required on one radio, but putting it on the first is the convention.
  3. The billing fieldset is disabled, and that cascades: #street matches :disabled, can't be focused, isn't validated (willValidate is false, so its required doesn't block submission) and isn't submitted: the form data is only [["speed", "express"]]. Yet street.disabled is false, because the element's own attribute isn't set. Check matches(":disabled") when it matters.
  4. The checkbox inside the first legend stays enabled. That's the spec's exception, designed for exactly this pattern: the "Different billing address" checkbox lives in the legend and, with a line of script (billing.disabled = !different.checked), turns the section on and off. The test enables the fieldset and the fields come back: enabled, validated, and city=Toronto is in the form data.
  5. fieldset.elements lists the controls inside it (3 here: the checkbox and two text inputs), handy for resetting or reading one section of a big form.
AdvancedFieldsets are awkward to style
Does this group need a fieldset?

A sign-up form has: an "I agree to the terms" checkbox, a "How did you hear about us?" set of five checkboxes, a date of birth split into day, month and year inputs, and a single "Email" field.

Show answer

The five checkboxes and the date of birth need fieldsets; the single checkbox and the email field don't.

What’s happening
  1. "I agree to the terms" is one control whose label says everything. A fieldset adds nothing.
  2. The five checkboxes answer one question, which none of their labels ("Search engine", "Friend", …) states on its own. A fieldset with legend "How did you hear about us?" makes each option understandable.
  3. Day, month and year are three inputs for one value. Labels "Day", "Month", "Year" need the context "Date of birth": a fieldset with that legend provides it (or use a single type="date" input instead).
  4. The email field has its own label and stands alone. Wrapping single fields in fieldsets just adds noise for screen-reader users.

#select, datalist and textarea

Added

<select> offers a fixed list of <option>s; the user must pick one of them (or several with multiple). Each option's value attribute is what gets submitted; if it has no value, its text is used. <optgroup label> groups options under a heading. A placeholder option ("Choose a country…") is an option with value="": with required, the browser treats an empty value as missing.

<datalist> is the opposite: a free-text <input list="id"> with suggestions. The user can pick one or type anything else; the suggestions don't constrain the value. That's right for "city" or "tags" fields where you have common answers but not a closed list. <textarea> is multi-line text. Its initial value is its content, not a value attribute, and its size comes from rows and cols, CSS, or the newer field-sizing: content.

Mental model: a select is a multiple-choice question: only the printed answers count. A datalist is a question with suggested answers in the margin: you can still write your own. A textarea is the blank space at the bottom of the form.

The value rules of select, datalist and textarea
CSS
.grid { display: grid; grid-template-columns: 110px 220px; gap: 8px 10px; align-items: start; font-size: 14px; }
select, input, textarea { font: inherit; }
#auto { field-sizing: content; min-height: 2lh; width: 200px; font: 14px/20px monospace; padding: 2px; }
#fixed { width: 200px; font: 14px/20px monospace; padding: 2px; }
HTML
<form id="f" class="grid">
  <label for="country">Country</label>
  <select id="country" name="country" required>
    <option value="">Choose a country…</option>
    <optgroup label="North America">
      <option value="CA">Canada</option>
      <option value="US">United States</option>
    </optgroup>
    <option>India</option>
  </select>

  <label for="langs">Languages</label>
  <select id="langs" name="langs" multiple size="3">
    <option selected>HTML</option><option selected>CSS</option><option>JavaScript</option>
  </select>

  <label for="city">City</label>
  <input id="city" name="city" list="cities" value="Hyderabad">
  <datalist id="cities"><option value="Toronto"></option><option value="Tokyo"></option></datalist>

  <label for="fixed">Notes</label>
  <textarea id="fixed" name="notes" rows="2" value="ignored">
  First line of notes</textarea>

  <label for="auto">Auto-height</label>
  <textarea id="auto" name="bio">one line</textarea>
</form>
Resultlive, in your browser
What’s happening
  1. The placeholder option. The country select starts on "Choose a country…", whose value is "", and the select is required, so it has valueMissing and Chrome's message is "Please select an item in the list." That's the standard pattern: an empty-value first option plus required forces a real choice.
  2. value vs text. Setting country.value = "US" selects "United States": the submitted value is the value attribute. The last option has no value, so its text "India" is its value. optgroups aren't options: options.length is 4 (placeholder, Canada, United States, India).
  3. multiple. The languages list has multiple and size="3", so it renders as a list box (role listbox; a normal select is a combobox). Each selected option is a separate entry: getAll("langs") gives ["HTML", "CSS"], the same repeated-key format as checkboxes.
  4. datalist. The city input offers "Toronto" and "Tokyo" as suggestions (input.list.options), but "Hyderabad" is still valid: a datalist never restricts the value. If only listed values are acceptable, use a select, or validate against the list yourself. Datalist isn't Baseline: MDN lists support in Chrome, Edge, Firefox and Safari but not Firefox for Android, where it degrades to a plain text field.
  5. textarea. Its value is its content: the newline directly after <textarea> is dropped by the parser, but the two spaces before "First line" are kept (" First line of notes"), so don't indent textarea content in templates. The value="ignored" attribute does nothing; defaultValue (what reset restores) is the content.
  6. field-sizing. Both textareas are 46px tall at first (two 20px lines, plus 2px padding and the default 1px border on each side). After five lines are put in each, the one with field-sizing: content grows to 106px (five lines) while the rows="2" one stays at 46px and scrolls. field-sizing is newly Baseline in 2026 (Chrome 123, Safari 26.2, and Firefox 152 in June 2026, per MDN); older browsers keep the fixed size, which is a fine fallback.
AdvancedCustomisable select with base-select

Customisable select

For decades you couldn't style a select's dropdown. The new customisable select lets you opt in with select, ::picker(select) { appearance: base-select; }, after which the button, the picker and even rich option content (icons, images) can be styled with normal CSS while keeping native keyboard and accessibility behaviour. It isn't Baseline: per MDN in October 2026 it's in Chrome and Edge 135+ and Safari 27, not Firefox. Browsers without it ignore the opt-in and show a normal select, so it's safe as progressive enhancement.

Detecting customisable select support
CSS
select, ::picker(select) { appearance: base-select; }
select { font: inherit; border: 1px solid #c7cfe0; border-radius: 8px; padding: 4px 10px; }
HTML
<select id="s"><option>Small</option><option>Medium</option><option>Large</option></select>
Resultlive, in your browser
What’s happening
  1. appearance: base-select on the select and its ::picker(select) opts into the new, fully stylable rendering. CSS.supports("appearance", "base-select") is true in Chrome 155, and the computed appearance is base-select.
  2. Nothing about the element's meaning changes: it's still a combobox with the value "Small", it submits the same way, and keyboard support is the browser's.
  3. In a browser without support, the declaration is invalid and dropped, so you get the classic select. Wrap any styles that only make sense in the new mode in @supports (appearance: base-select) { … } (see @supports).
select or datalist?

Which would you use for: (a) a T-shirt size (S, M, L, XL), (b) a "Job title" field where you'd like to suggest common titles, (c) a country with 195 options, (d) tags for a blog post?

Show answer

(a) select (or radios), (b) datalist, (c) select, ideally with a search, (d) datalist or a custom tag input.

What’s happening
  1. (a) Sizes are a small closed set; any other value is wrong. A select, or radio buttons in a fieldset since there are only four, which shows every choice at once.
  2. (b) Titles are open-ended: suggestions help, but "Principal Android Engineer" must be allowed. That's a datalist.
  3. (c) The list is closed (an invalid country shouldn't be accepted), so select. With 195 options, typing the first letters jumps to them in a native select; for a better experience, a searchable combobox component, or the customisable select where it's supported.
  4. (d) Tags are free text with common suggestions, so a datalist gives suggestions per tag; entering several tags in one field usually needs a small custom component on top.

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.