A useful web form starts with semantic HTML, not a JavaScript framework. Labels and native input constraints make a form easier to use, while a little JavaScript can improve how errors are explained. Neither browser validation nor JavaScript can replace checks on the server that receives the submission.
This guide builds a small contact form with accessible labels, native constraints, and an optional error summary. The same approach works for signup, feedback, and other forms that accept ordinary text.
Start with labeled controls and native constraints
Give every control a name so its value can be submitted, and connect it to a visible <label> with a matching for and id. Choose an input type that describes the expected value. Add constraints that are useful to the person filling out the form:
<form id="contact-form" action="/contact" method="post">
<div id="error-summary" role="region" aria-labelledby="error-title" tabindex="-1" hidden>
<h2 id="error-title">Check these fields</h2>
<ul id="error-list"></ul>
</div>
<div>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required
aria-describedby="name-error">
<p id="name-error"></p>
</div>
<div>
<label for="email">Email address</label>
<p id="email-hint">We will use this address to reply.</p>
<input id="email" name="email" type="email" autocomplete="email" required
aria-describedby="email-hint email-error">
<p id="email-error"></p>
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" minlength="10"
maxlength="2000" required aria-describedby="message-error"></textarea>
<p id="message-error"></p>
</div>
<button type="submit">Send message</button>
</form>
The action should point to the server route that handles this form. A form without a working server endpoint can still look correct in a browser, but it cannot deliver the message.
Use the right constraint for the data:
requiredmarks a field that cannot be empty.type="email"checks that an entry has a basic email-address shape; it cannot confirm that the address exists or belongs to the visitor.minlengthandmaxlengthput bounds on text length. Enforce the same limits on the server.min,max, andstepsuit numeric and date-like inputs. Choose these only when they match the real business rule.patterncan constrain a specific format, but a complex regular expression is often harder to understand than a short instruction and a server-side check.
Native constraints are a good baseline: browsers can prevent a normal form submission and report which control needs attention. Exact messages and presentation vary between browsers, so don't depend on their wording or appearance as your only guidance.
Add clear errors without losing the native fallback
For many forms, native browser feedback is enough. If you need a consistent error summary, you can add one with JavaScript while keeping native validation when JavaScript is unavailable. Leave novalidate off the HTML form; set the noValidate property only after the custom handlers are ready.
Save this as a deferred script, such as form-validation.js, and load it with <script src="/form-validation.js" defer></script>. The defer attribute lets the browser build the form before the script queries it.
const form = document.querySelector("#contact-form");
const summary = document.querySelector("#error-summary");
const summaryList = document.querySelector("#error-list");
const fields = [...form.querySelectorAll("input, textarea, select")];
function updateFieldError(field) {
const error = document.getElementById(`${field.id}-error`);
const valid = field.validity.valid;
error.textContent = valid ? "" : field.validationMessage;
if (valid) {
field.removeAttribute("aria-invalid");
} else {
field.setAttribute("aria-invalid", "true");
}
return valid;
}
function renderErrorSummary(invalidFields) {
summaryList.replaceChildren(
...invalidFields.map((field) => {
const item = document.createElement("li");
const link = document.createElement("a");
const label = field.labels[0]?.textContent.trim() || field.name;
link.href = `#${field.id}`;
link.textContent = `${label}: ${field.validationMessage}`;
link.addEventListener("click", (event) => {
event.preventDefault();
field.focus();
});
item.append(link);
return item;
}),
);
summary.hidden = invalidFields.length === 0;
}
form.addEventListener("submit", (event) => {
const invalidFields = fields.filter((field) => !updateFieldError(field));
renderErrorSummary(invalidFields);
if (invalidFields.length > 0) {
event.preventDefault();
summary.focus();
}
});
for (const field of fields) {
const refreshError = () => {
if (!field.hasAttribute("aria-invalid")) return;
updateFieldError(field);
renderErrorSummary(fields.filter((candidate) => candidate.hasAttribute("aria-invalid")));
};
field.addEventListener("input", refreshError);
field.addEventListener("change", refreshError);
}
form.noValidate = true;
The script uses each control's validity state and browser-provided validationMessage. It associates field text through aria-describedby, marks currently invalid controls with aria-invalid, and focuses a summary with links back to the fields. Text errors—not just a red border—help people identify and correct a problem.
The summary is created with DOM methods and text content rather than inserting submitted values as HTML. If you add custom validation rules, keep the message concise and tell the visitor how to fix the value. Clear a field's error when it becomes valid, as this example does.
If you do not need a custom summary, keep native validation and use form.checkValidity() to test whether constraints pass or form.reportValidity() to ask the browser to report invalid controls. For a custom rule that the built-in constraints cannot express, setCustomValidity("A helpful message") marks a control invalid; call setCustomValidity("") when the rule passes to clear that state.
Validate again on the server
Everything in the browser is under the visitor's control. A request can be sent without your page's JavaScript, and client-side checks can be changed or bypassed. Validate the submitted values on the server before using or storing them.
For a small contact form, server checks might confirm that required values are present, enforce the same length limits, and reject fields that are not part of the form. Treat data according to its purpose: validate format where a specific format is required, and avoid rejecting ordinary names or messages with overly restrictive character rules. Use parameterized database queries and escape data for the output context when displaying it later; validation alone does not prevent SQL injection or cross-site scripting.
Form validation also does not provide spam protection, authorization, or CSRF protection. Add the appropriate server-side protections for your application, and do not treat a hidden field or a disabled submit button as a security boundary.
Test the experience, not just the happy path
Before shipping, try the form with an empty required field, a malformed email address, text at the length limits, and a valid submission. Then check that:
- Every field has a visible, programmatically associated label and useful instructions.
- You can reach and submit the form with a keyboard.
- An error is explained in text, associated with its field, and the summary links move focus to the right control.
- Correcting a field clears its error without erasing other errors.
- The form still shows browser validation if the custom script is disabled or fails to load.
- The server rejects invalid submissions even when browser validation is bypassed.
Try these cases in the browsers and assistive technologies your audience uses. Native validation messages, autofill behavior, and focus presentation can differ. The W3C forms validation tutorial offers further guidance on instructions, required fields, and communicating errors accessibly.
If you are building on WordPress rather than handling submissions in your own application, a plugin may be a better fit than a custom endpoint; see our guide to contact form plugins for WordPress.