HTML Forms: The Complete Beginner's Guide with Examples

The very first paid project I shipped as a junior developer was a lead-gen form for a local coaching institute. It looked fine on my laptop — and lost the client roughly 40% of mobile submissions in the first week because I'd used type="text" on the phone field and skipped autocomplete. That one form taught me more about HTML than any tutorial ever did. Since then, across three years of Laravel and PHP client work, I've built (and re-built) hundreds of forms — checkouts, admin panels, multi-step onboarding — and the patterns below are what actually survives production. This is the guide I wish someone had handed me on day one.
The anatomy of a form
Every form has three core parts: the <form> wrapper (with action and method), one or more form controls (<input>, <select>, <textarea>), and a submit control. Wrap each control in a <label> so screen readers and mobile users can tap the label to focus the field.
<form action="/subscribe" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" />
<button type="submit">Subscribe</button>
</form>Pick the right input type
- type="email" — built-in validation + email keyboard on mobile
- type="tel" — numeric keyboard with symbols
- type="number" with inputmode="numeric" — quantity fields
- type="date" — native date picker
- type="search" — clear button + search keyboard
Native validation, no JavaScript needed
Attributes like required, minlength, maxlength, pattern, and min/max give you free client-side validation. Combine with :invalid and :user-invalid CSS to style errors only after the user has interacted.
Accessibility essentials
- Always pair a <label for> with every <input id>
- Group related fields with <fieldset> and <legend>
- Use aria-describedby to link help text
- Never rely on placeholder as a label
Common mistakes
- Using <div> click handlers instead of <button>
- Forgetting autocomplete tokens (kills mobile UX)
- Disabling submit until valid (confuses users)
- Wrong input type on phone fields
A form I rebuilt that doubled completions
A client ran a quote request form that got plenty of visits and very few submissions. Nothing was broken. It was one long column of eighteen text inputs with placeholder-only labels, no grouping, no inline validation, and a Submit button that gave no feedback until the page reloaded. We rebuilt it as four fieldsets, real labels, correct input types, inline errors on blur, and a disabled button with a Sending state. Same fields, same backend, roughly double the completed submissions in the following month. Forms are almost never a design problem — they are a markup and feedback problem.
Structure before styling
<form action="/quote" method="post" novalidate>
<fieldset>
<legend>Your details</legend>
<div class="field">
<label for="name">Full name</label>
<input id="name" name="name" type="text" autocomplete="name" required>
</div>
<div class="field">
<label for="email">Work email</label>
<input id="email" name="email" type="email"
autocomplete="email" required aria-describedby="email-help">
<p id="email-help" class="help">We reply within one business day.</p>
</div>
</fieldset>
<button type="submit">Request a quote</button>
</form>Three details in that snippet do a lot of work. The label's for matches the input's id, so tapping the label focuses the field — a genuinely large hit area gain on mobile. The autocomplete token lets the browser fill the field from saved data. And aria-describedby links the help text so a screen reader reads it as part of the field, not as stray text.
Validation: let the browser do the first pass
Native constraint validation handles required, type, minlength, maxlength, min, max, and pattern with zero JavaScript. I keep all of it and add novalidate on the form only so I can control when and how messages appear, then trigger checkValidity myself.
const form = document.querySelector("form");
form.addEventListener("submit", (e) => {
if (!form.checkValidity()) {
e.preventDefault();
for (const field of form.elements) {
if (!field.willValidate) continue;
setError(field, field.validity.valid ? "" : field.validationMessage);
}
form.querySelector(":invalid")?.focus();
}
});Validating on blur rather than on every keystroke is the detail users feel. Showing "invalid email" after someone has typed two characters is hostile; showing it when they leave the field is helpful.
Errors that a screen reader actually announces
<input id="email" aria-invalid="true" aria-describedby="email-error">
<p id="email-error" role="alert">Enter an email address like you@company.com</p>aria-invalid marks the field as failing, aria-describedby ties the message to it, and role="alert" makes the message announce as soon as it appears. Red text alone communicates nothing to a screen reader and nothing to a colour-blind user either — always pair colour with text and an icon.
Server-side validation is not optional
Everything above is a convenience layer. Anyone can open DevTools, delete the required attribute, and submit whatever they like, and an attacker will not use your form at all — they will post directly to the endpoint. Every rule you enforce in the browser must be enforced again on the server. In Laravel that is a form request class; the principle is the same in any stack.
Small details that add up
- Use type="submit" on the submit button — a bare button inside a form defaults to submit in most browsers but being explicit avoids surprises
- Disable the button and change its label while the request is in flight to prevent double submissions
- Keep whatever the user typed after a failed submit; making people retype a form is how you lose them
- Group related inputs in a fieldset with a legend so screen readers announce the context
- For multi-step forms, show progress and let people go back without losing data
- Give the file input an accept attribute and validate the size before uploading
Wrapping up
Good forms come from boring correctness: a real label on every input, the right type, native validation surfaced at the right moment, accessible error messages, and honest feedback while submitting. None of it is fashionable and all of it shows up in the completion rate.
Frequently asked questions
Should I use novalidate on my forms?
Only if you are replacing the browser's default bubbles with your own inline messages, which is what I usually do for design consistency. Keep the validation attributes themselves — novalidate suppresses the default UI, not the validity API you call from JavaScript.
GET or POST for a form?
GET when the submission is a query whose result should be linkable and bookmarkable, like a search or filter. POST for anything that creates, changes, or deletes data, or contains anything private — GET parameters end up in browser history and server logs.
How do I stop spam without a CAPTCHA?
A honeypot field hidden from users but visible to bots catches a surprising amount, and rejecting submissions that arrive within a second or two of page load catches more. Add a real CAPTCHA only if those stop being enough.
Should I validate on the client or server?
Both. Client validation improves UX; server validation is the only one you can trust.
Is <form> required if I submit via JavaScript?
Yes — it enables Enter-to-submit, browser autofill, and password managers.
External references
Enjoyed this article?
Share it with a fellow developer or explore more tutorials in our blog.
More articles