# Forms That Work

> Forms are how the web collects input - and most of what makes them good or broken lives in details people skip. Build one real signup form and wire up labels, validation, and submission the right way.


---

# Forms That Work

Every account you've ever created, every comment you've posted, every search you've run started with
a form. They look simple - a few inputs and a button - which is exactly why they're easy to get wrong.
A form with no `<label>` is unusable for screen reader users. A form with only client-side validation
lets bad data through if JavaScript fails. A form that navigates away on submit breaks the smooth,
single-page feel modern apps promise.

This guide builds one form and takes it seriously: a signup form with a name, an email, a password,
and an "I agree to terms" checkbox. By the end it has proper labels, layered validation, and a
JavaScript-powered submit that talks to a server without reloading the page.

## How to read this

- **Building your first form?** Read in order - phase 1 gets the markup right, phase 2 adds
  validation, phase 3 wires up submission.
- **Already comfortable with inputs and labels?** Jump to
  [Phase 2: Validation: Built-in vs. Custom](02-validation-built-in-vs-custom.md).

## The phases

1. **[Inputs, Labels, and Why `<label>` Matters](01-inputs-labels-and-why-label-matters.md)** - the
   input types you'll actually use, why `<label for="id">` is load-bearing rather than decorative, and
   grouping related fields with `<fieldset>`/`<legend>`.
2. **[Validation: Built-in vs. Custom](02-validation-built-in-vs-custom.md)** - `required`, `pattern`,
   `minlength`, `type="email"`, the `:valid`/`:invalid` CSS hooks, the Constraint Validation API, and
   where JavaScript still has to step in.
3. **[Submitting Data: GET vs. POST, FormData, and Fetch](03-submitting-data-get-vs-post-formdata-and-fetch.md)** -
   native form submission versus `preventDefault()` plus `fetch`, building a `FormData` object, and
   handling both success and server-side validation errors.

> This guide covers the form itself, not layout or making it usable on a phone screen - that's
> [Responsive Design](/guides/responsive-design). It also only lightly touches accessibility; the full
> treatment is [Accessibility From Day One](/guides/accessibility-from-day-one).


---

# Inputs, Labels, and Why `<label>` Matters

Start the signup form: name, email, password, and a checkbox agreeing to terms. The markup looks
trivial, but each piece carries weight most people skip past.

## Input types are not interchangeable

Every `<input>` has a `type`, and the type changes more than validation - it changes the keyboard a
phone shows, the browser's built-in checks, and how the value gets stored.

```html
<input type="text" name="fullName">
<input type="email" name="email">
<input type="password" name="password">
<input type="checkbox" name="agree">
```

- **`text`** - a plain string. No format checking, no special keyboard.
- **`email`** - mobile browsers show an `@` key; desktop browsers reject submission if the value has
  no `@` and domain shape, even before you write a line of JavaScript.
- **`password`** - masks the characters as dots and tells password managers this field is sensitive.
- **`checkbox`** - a boolean. Checked or not; there's no "value" the user types.

There's also `radio` for picking one option from a set:

```html
<input type="radio" name="plan" value="free" id="plan-free">
<label for="plan-free">Free</label>
<input type="radio" name="plan" value="pro" id="plan-pro">
<label for="plan-pro">Pro</label>
```

Radio buttons only work as a group when they share the same `name` - that's how the browser knows
they're mutually exclusive. Give each one a different `value` so you know which was picked.

📝 **Terminology.** `type="email"` doesn't guarantee a *real, deliverable* email address - it only
checks the address is shaped like one (`something@something.something`). Confirming the address exists
still needs a verification email.

## `<label>` is not decoration

A `<label>` wrapped around text next to an input looks the same with or without the `for` attribute.
The difference only shows up when someone interacts with the page differently than you did while
building it.

```html
<label for="email">Email</label>
<input type="email" id="email" name="email">
```

The `for` attribute must match the input's `id`. That connection does two concrete things:

1. **Bigger click target.** Clicking the label text "Email" focuses the input - not just clicking the
   box itself. For checkboxes and radio buttons this matters even more: the label turns a tiny 16px
   square into a click target the width of the whole sentence.
2. **Screen reader association.** Without `for`/`id`, a screen reader announces "edit text, blank." With
   it, the reader announces "Email, edit text, blank" - the user knows what they're filling in before
   they start typing. This is table stakes, not a nice-to-have; the full accessibility treatment for
   forms and beyond is in [Accessibility From Day One](/guides/accessibility-from-day-one).

⚠️ **Gotcha.** A `placeholder` is not a label. Placeholder text disappears the moment someone starts
typing, has no association a screen reader relies on, and often fails color-contrast checks because
it's styled light gray by default. Use `placeholder` for a format hint ("MM/DD/YYYY") alongside a real
`<label>`, never as a replacement for one.

You can skip `for`/`id` by wrapping the input inside the label instead:

```html
<label>
  Email
  <input type="email" name="email">
</label>
```

This works and is valid HTML, but it's harder to style precisely (the input inherits label layout
quirks) and harder to scan in longer forms. Prefer explicit `for`/`id` pairs once a form has more than
one or two fields.

## Grouping related inputs with `<fieldset>`

The "I agree to terms" checkbox is one field, but the moment a form has more than one option that
belongs together - a set of radio buttons, several checkboxes for notification preferences - group them
with `<fieldset>` and describe the group with `<legend>`:

```html
<fieldset>
  <legend>Notify me about</legend>
  <label for="notify-news"><input type="checkbox" id="notify-news" name="notify" value="news"> Product news</label>
  <label for="notify-tips"><input type="checkbox" id="notify-tips" name="notify" value="tips"> Tips</label>
</fieldset>
```

A screen reader announces the legend before each field inside the fieldset, so "Product news" is heard
as "Notify me about, Product news" - the group's purpose travels with every item in it. Visually,
browsers render a `<fieldset>` with a border and the `<legend>` sitting on top of it by default; override
that with CSS if it doesn't fit your design, but keep the elements themselves.

## The signup form so far

```html
<form>
  <div>
    <label for="fullName">Full name</label>
    <input type="text" id="fullName" name="fullName" required>
  </div>

  <div>
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required>
  </div>

  <div>
    <label for="password">Password</label>
    <input type="password" id="password" name="password" required>
  </div>

  <fieldset>
    <legend>Terms</legend>
    <label for="agree">
      <input type="checkbox" id="agree" name="agree" required>
      I agree to the terms of service
    </label>
  </fieldset>

  <button type="submit">Create account</button>
</form>
```

Every input has a matching label. The checkbox lives inside its own label so clicking the sentence
toggles it. Next phase turns those stray `required` attributes into a real validation strategy.

Check what you just covered:

```quiz
[
  {
    "q": "What does `<label for=\"email\">` connect to?",
    "choices": ["The form's action URL", "An input with a matching id", "The page's title"],
    "answer": 1,
    "explain": "The for attribute must match the input's id - that pairing is what makes the label clickable and screen-reader-associated."
  },
  {
    "q": "Why shouldn't a placeholder replace a label?",
    "choices": ["Placeholders are deprecated", "Placeholder text disappears on typing and isn't reliably read by screen readers", "Placeholders only work on checkboxes"],
    "answer": 1,
    "explain": "Once the user types, the placeholder is gone - and it was never announced as a real label to begin with."
  },
  {
    "q": "What makes a group of radio buttons mutually exclusive?",
    "choices": ["Wrapping them in a <fieldset>", "Giving them the same name attribute", "Giving them the same id"],
    "answer": 1,
    "explain": "Radio buttons only exclude each other within a shared name group. id must stay unique per element."
  }
]
```


---

# Validation: Built-in vs. Custom

The browser already knows how to reject a blank required field or a malformed email - no JavaScript
needed. The signup form from phase 1 gets that validation almost free. What it can't do alone is check
whether two passwords match or whether an email is already taken. That split is the whole story of this
phase.

## Built-in validation attributes

A handful of HTML attributes turn on checks the browser enforces before it lets the form submit:

```html
<input type="text" name="fullName" required minlength="2">
<input type="email" name="email" required>
<input type="password" name="password" required minlength="8"
       pattern="(?=.*\d)(?=.*[a-z]).{8,}"
       title="At least 8 characters, including a number">
```

- **`required`** - the field can't be empty (or unchecked, for a checkbox).
- **`minlength` / `maxlength`** - character count bounds for text-like inputs.
- **`type="email"`** - rejects values without a plausible `name@domain` shape.
- **`pattern`** - a regular expression the value must fully match. Here it demands 8+ characters with at
  least one digit and one lowercase letter. The `title` attribute supplies the message some browsers
  show on failure.

Try submitting the form with an empty name field, and Chrome or Firefox pops a native "Please fill out
this field" bubble pointing right at it - the page never reloads, no code ran. That's the browser's
Constraint Validation engine doing its job before your form even touches the network.

⚠️ **Gotcha.** `pattern` matches the *entire* value, not a substring search. Forgetting to anchor
mentally trips people up less than forgetting that a pattern like `\d{3}` will fail on `"abc123abc"`
unless you meant it to match anywhere - always test with `.test()` in the console before shipping.

## Styling valid and invalid states

CSS reads these same constraints through the `:valid` and `:invalid` pseudo-classes, updating live as
the user types:

```css
input:invalid {
  border-color: #d33;
}

input:valid {
  border-color: #2a2;
}

input:placeholder-shown:invalid {
  border-color: initial; /* don't shame an empty field the user hasn't touched yet */
}
```

That last rule matters: every empty `required` field is `:invalid` by default, so without it every input
shows red before the user has typed anything. `:placeholder-shown` is a decent proxy for "still empty" -
combine it with `:invalid` to only flag fields the user actually got wrong.

## The Constraint Validation API

Attributes cover most cases; the Constraint Validation API is the JavaScript layer underneath them, for
when you need more control over *when* and *how* errors show.

```js
const password = document.getElementById('password');

if (password.checkValidity()) {
  console.log('Password meets the pattern and minlength.');
} else {
  console.log(password.validationMessage);
}
```

`checkValidity()` runs the same checks the browser runs on submit and returns `true`/`false`.
`validationMessage` holds the browser's human-readable explanation for the first failing rule.

`setCustomValidity()` lets you inject your own rule into that same system:

```js
const confirmField = document.getElementById('confirmPassword');

confirmField.addEventListener('input', () => {
  if (confirmField.value !== password.value) {
    confirmField.setCustomValidity('Passwords do not match');
  } else {
    confirmField.setCustomValidity(''); // clear it, or the field stays invalid forever
  }
});
```

Setting a non-empty string marks the field invalid and makes that string the message the browser shows
on submit attempt - it plugs straight into the native validation bubble, no custom UI needed. The empty
string on the else branch is not optional: `setCustomValidity` sticks until you explicitly clear it,
even after the user fixes the value.

## Where JavaScript has to take over

HTML attributes check *shape*. They can't check things that depend on another field or another system:

1. **Matching two fields.** "Confirm password" only makes sense compared against "password" -
   `pattern` can't reference a second input's value. `setCustomValidity()`, as above, is the standard
   way to wire that comparison into native validation.
2. **Server-side checks.** "Is this email already registered?" requires asking the server. No HTML
   attribute can know that; you send a request and act on the answer.

```js
async function checkEmailAvailable(email) {
  const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
  const { available } = await res.json();
  return available;
}

emailField.addEventListener('blur', async () => {
  const available = await checkEmailAvailable(emailField.value);
  emailField.setCustomValidity(available ? '' : 'That email is already registered');
});
```

Running this check on `blur` (when the field loses focus) rather than on every keystroke avoids firing
a request per character typed.

💡 **Key point.** Built-in validation is not a substitute for server-side validation - it's a substitute
for *annoying round trips*. A user with JavaScript disabled, or a request sent straight to your API with
`curl`, bypasses every attribute and every `setCustomValidity()` call. The server must re-check
everything (required fields, formats, uniqueness) regardless of what the browser already checked. Client
validation is for user experience; server validation is for correctness.

Check your understanding:

```quiz
[
  {
    "q": "What does `pattern=\"\\d{3}\"` require of the input's value?",
    "choices": ["The value must contain three digits somewhere", "The entire value must be exactly three digits", "The value must be a number less than 1000"],
    "answer": 1,
    "explain": "pattern matches the whole value, not a substring - \\d{3} alone only matches a value that is exactly 3 digits."
  },
  {
    "q": "What happens if you call setCustomValidity('some message') and never clear it?",
    "choices": ["It clears automatically once the field is valid again", "The field stays invalid until you call setCustomValidity('')", "It only affects the next form submission"],
    "answer": 1,
    "explain": "A custom validity message persists until explicitly reset to an empty string, even if the underlying condition is fixed."
  },
  {
    "q": "Why does client-side validation not replace server-side validation?",
    "choices": ["Browsers validate too slowly", "A request can bypass the browser entirely (JS disabled, curl, a bug), so the server must re-check", "Server-side validation is only for checkboxes"],
    "answer": 1,
    "explain": "Anything that hits your API directly skips HTML/JS validation, so the server is the only place correctness can be guaranteed."
  }
]
```


---

# Submitting Data: GET vs. POST, FormData, and Fetch

Submit the signup form as-is and the whole page reloads - the browser navigates to a new URL built from
the form's data. That's native form submission, and it still has a place. Most modern apps intercept it
instead, sending the data with `fetch` and updating the page without a reload. Both matter; know when
to reach for which.

## Native submission: GET vs. POST

Every `<form>` has a `method`, and it changes where the data goes:

```html
<form method="get" action="/search">
  <input type="text" name="q">
</form>
```

A `GET` form appends its fields to the URL as a query string: submitting `q=svelte` navigates to
`/search?q=svelte`. That's right for searches and filters - the result is bookmarkable and shareable,
and "search again" is just reloading the URL.

```html
<form method="post" action="/signup">
  <input type="text" name="fullName">
</form>
```

A `POST` form sends the data in the request body instead, invisible in the URL. That's right for
anything that changes state or carries sensitive data - a signup form with a password has no business
appearing in a URL, browser history, or a server access log.

Without JavaScript, either version fully navigates: the browser follows `action`, the server responds
with a new page (or a redirect), and the current page is gone. That's a real, working form with zero
lines of JavaScript - worth remembering when you don't need an SPA-style flow at all.

## Intercepting submission with `preventDefault()`

To keep the user on the page, listen for the form's `submit` event and stop the default navigation:

```js
const form = document.querySelector('form');

form.addEventListener('submit', (event) => {
  event.preventDefault();
  // handle the data yourself from here
});
```

`preventDefault()` stops the browser from navigating anywhere. If you forget it, everything else in
this phase still runs, but the page reloads immediately after, and your JavaScript never gets to react
to the result.

## Building a `FormData` object

`FormData` reads every named field from a form element in one call - no manually grabbing each input by
id:

```js
form.addEventListener('submit', (event) => {
  event.preventDefault();

  const data = new FormData(form);
  console.log(data.get('email'));       // one field
  console.log(Object.fromEntries(data)); // the whole form as a plain object
});
```

`FormData` picks up `name` attributes, not `id` - a field with no `name` is silently skipped, which is
the most common reason "my form data is missing a field" turns out to be a typo'd or missing `name`.
Checkboxes are the other trap: an *unchecked* checkbox sends nothing at all, not `false` - `data.get('agree')`
returns `null` unless the box was checked.

## Sending it with `fetch`

`FormData` can go straight into a `fetch` call as the request body - the browser sets the right
`Content-Type` header automatically:

```js
form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const data = new FormData(form);

  const response = await fetch('/api/signup', {
    method: 'POST',
    body: data,
  });

  if (response.ok) {
    const result = await response.json();
    showSuccess(`Welcome, ${result.name}!`);
  } else {
    const error = await response.json();
    showError(error.message);
  }
});
```

`response.ok` is `true` for any `2xx` status and `false` otherwise - it's the quickest way to branch
between success and failure without checking exact status codes. A `422` with `{"message": "Email
already registered"}` lands in the `else` branch, where `showError` can put that exact message next to
the email field instead of a generic "something went wrong."

If you'd rather send JSON instead of `multipart/form-data` (some APIs expect that), convert first:

```js
const data = new FormData(form);
const json = Object.fromEntries(data);

await fetch('/api/signup', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(json),
});
```

⚠️ **Gotcha.** `fetch` only rejects its promise on a network failure (no connection, DNS failure) - a
`404` or `500` response still resolves normally with `response.ok` set to `false`. Wrap in `try`/`catch`
for the network case, and check `response.ok` separately for the HTTP-status case; missing either one
means some class of failure shows the user nothing at all.

```js
try {
  const response = await fetch('/api/signup', { method: 'POST', body: new FormData(form) });
  if (!response.ok) {
    const error = await response.json();
    showError(error.message);
    return;
  }
  showSuccess('Account created.');
} catch (networkError) {
  showError('Could not reach the server. Check your connection.');
}
```

## Putting the whole form together

```js
const form = document.querySelector('#signup-form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  if (!form.checkValidity()) {
    form.reportValidity(); // triggers the native error bubbles
    return;
  }

  try {
    const response = await fetch('/api/signup', { method: 'POST', body: new FormData(form) });
    const result = await response.json();

    if (response.ok) {
      window.location.href = '/welcome';
    } else {
      showError(result.message);
    }
  } catch {
    showError('Could not reach the server. Check your connection.');
  }
});
```

`form.checkValidity()` runs every constraint from phase 2 (required, pattern, minlength, your custom
`setCustomValidity` calls) in one shot; `reportValidity()` shows the same native bubbles a real submit
would have. Running both before the `fetch` call means the built-in and server checks stay layered
instead of the JavaScript path silently skipping past HTML validation.

Check your understanding:

```quiz
[
  {
    "q": "Why does a signup form use method=\"post\" instead of \"get\"?",
    "choices": ["POST is faster than GET", "GET puts the data in the URL, exposing it in history and logs", "GET forms cannot include a password field"],
    "answer": 1,
    "explain": "GET appends form data to the URL as a query string - fine for a search, unacceptable for a password."
  },
  {
    "q": "What does FormData.get('agree') return if the checkbox was left unchecked?",
    "choices": ["false", "\"\" (empty string)", "null"],
    "answer": 2,
    "explain": "An unchecked checkbox contributes no field at all to FormData, so get() returns null rather than a false value."
  },
  {
    "q": "When does fetch's returned promise reject?",
    "choices": ["On any 4xx or 5xx response", "Only on a network-level failure, like no connection", "Whenever response.ok is false"],
    "answer": 1,
    "explain": "fetch resolves normally even for error status codes - you check response.ok yourself. The promise only rejects on network failures."
  }
]
```

## Where to go next

The form now renders accessibly, validates on both sides, and submits without a page reload. What
happens after the browser gets that response - how it turns HTML, CSS, and the DOM into pixels on
screen in the first place - is the subject of
[How the Browser Renders a Page](/guides/how-the-browser-renders-a-page).
