Validating Email Fields with Native Input Attributes
An <input type="email"> gives you syntactic address checking, a multiple list mode, and pattern tightening for free through the browser’s Constraint Validation API, so this page shows exactly which attributes govern that behaviour and how to surface their errors without shipping a regex library.
When to Use This Attribute-Driven Approach
Reach for native type="email" attributes when the address only needs to be well-formed and present at submit time — no deliverability guarantee, no MX lookup, no “is this user already registered” question. The browser enforces required, type, pattern, maxlength, and multiple at zero runtime cost and localises the error text for you.
- Use native attributes when the rule is syntactic: shape, presence, length, or a corporate-domain
pattern. - Escalate to asynchronous server checks when you must confirm the mailbox exists or is unique.
- Escalate to Zod schema validation when the email is one field in a larger typed payload you validate as a unit.
- For tightening the accepted shape itself, pair this recipe with HTML5 Pattern Attribute Regex Examples.
The novalidate + reportValidity() Baseline for type="email"
The canonical pattern on this site ships <form novalidate> to suppress the browser’s automatic on-submit bubble, then drives validity through one explicit reportValidity() call. That gives you a single, predictable place to decide when errors appear while still letting the type="email" attribute do the parsing.
<form id="signup" novalidate>
<label for="email">Work email</label>
<input
id="email"
name="email"
type="email"
required
maxlength="254"
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
autocomplete="email"
inputmode="email"
/>
<button type="submit">Create account</button>
</form>
// The form ships `novalidate`, so nothing validates until we ask it to.
const form = document.querySelector<HTMLFormElement>("#signup")!;
const email = form.elements.namedItem("email") as HTMLInputElement;
form.addEventListener("submit", (event: SubmitEvent) => {
// One manual gate through the Constraint Validation API.
// reportValidity() runs every native attribute rule (type, required,
// pattern, maxlength) and, on failure, shows the browser bubble
// and focuses the first invalid control.
if (!form.reportValidity()) {
event.preventDefault();
return;
}
// At this point the address is syntactically valid and present.
// Any deeper check (uniqueness, deliverability) happens after here.
event.preventDefault(); // demo only; swap for your submit
console.log("Submitting", email.value.trim());
});
Because validation flows through reportValidity(), you can reuse the exact same call for every field on the form — see checkValidity vs reportValidity when you need to test validity silently before deciding whether to surface anything.
Email Attribute Reference: type, required, pattern, multiple, maxlength
| Option | Type | Default | Purpose |
|---|---|---|---|
type="email" |
token | text |
Enables built-in address parsing; sets typeMismatch when the value is not a valid address. |
required |
boolean | absent | Sets valueMissing when the field is empty; combine with type for “present and well-formed”. |
pattern |
regex string | none | Adds a patternMismatch rule on top of type parsing — e.g. restrict to one corporate domain. |
multiple |
boolean | absent | Accepts a comma-separated list; each address is validated independently. |
maxlength |
integer | none | Caps character count; 254 is the practical RFC address ceiling. |
minlength |
integer | none | Sets tooShort only after the user edits the field. |
inputmode="email" |
token | derived | Hints the on-screen keyboard; does not affect validity. |
autocomplete="email" |
token | on |
Enables browser/password-manager autofill; no validity effect. |
Note that type="email" deliberately accepts addresses without a dot in the domain (e.g. dev@localhost). If you need to require a TLD, add the pattern shown in the baseline — the browser then requires both the native shape and the pattern to pass.
Verification Steps
import { test, expect } from "@playwright/test";
test("empty email is blocked and the input gains focus", async ({ page }) => {
await page.goto("/signup");
await page.getByRole("button", { name: "Create account" }).click();
const email = page.locator("#email");
// reportValidity() focuses the first invalid control.
await expect(email).toBeFocused();
const flag = await email.evaluate(
(el: HTMLInputElement) => el.validity.valueMissing,
);
expect(flag).toBe(true);
});
Edge Cases & Failure Modes
Leading/trailing whitespace passes type but breaks your backend. The browser trims surrounding spaces before parsing, so a@b.com is reported valid, yet the raw input.value may still carry them depending on how it was set programmatically. Always email.value.trim() before persisting, and never trust the visual field for the canonical value.
A too-strict pattern rejects valid plus-addressing and subdomains. Patterns like ^\w+@\w+\.\w+$ silently reject user+tag@mail.example.co.uk. Prefer the permissive [^@\s]+@[^@\s]+\.[^@\s]+ baseline and, when you truly need a bespoke message, pair it with custom validity messages via setCustomValidity rather than tightening the regex further.
The native bubble is not accessible enough for a production form. The default reportValidity() bubble is transient and unreadable by some assistive tech. When accessibility matters, render your own message region driven by the same ValidityState — see accessible error messaging and focus management after a failed submit.
The Parsing Rule Behind type="email"
It helps to know exactly what the browser accepts, because the definition is narrower than RFC 5322 and wider than most hand-written regexes. The HTML Living Standard defines a valid single address as a string that matches this pattern:
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
Three consequences fall out of that spec text and explain most “why is this valid/invalid?” surprises:
- Quoted local parts and comments are rejected. RFC 5322 permits
"john doe"@example.comand inline(comment)syntax; the HTML rule does not. The standard calls this a “willful violation” — a deliberate simplification, because those forms never appear in real sign-up flows and would only widen your attack surface. - A bare hostname passes. As the reference table notes,
dev@localhostsatisfies the domain production because a single label with no dot is legal. This is why a dotted-TLD requirement must come from yourpattern, not fromtypealone. - Internationalised (Unicode) local parts fail.
试@例子.测试is invalid against the native rule; the domain must be punycoded and the local part stays ASCII. If you accept IDN addresses, validate the shape server-side and keep the native check as a coarse pre-filter.
Because this regex is fixed and identical across engines, typeMismatch is one of the few validity flags whose behaviour you can rely on without per-browser testing.
Distinguishing typeMismatch from patternMismatch
When you stack type and pattern, a single field can fail for two structurally different reasons, and a good form tells them apart. typeMismatch means “this is not shaped like an email at all”; patternMismatch means “it is a valid email, but not one you accept.” Reading the flags lets you write a message that names the actual problem instead of a generic “invalid email.”
// Map the specific ValidityState flag to a targeted message.
// Order matters: check the coarsest failure (empty) first, then
// native shape, then your bespoke pattern constraint.
function emailErrorFor(input: HTMLInputElement): string | null {
const v = input.validity;
if (v.valid) return null;
if (v.valueMissing) return "Enter your work email address.";
if (v.typeMismatch) return "That doesn't look like an email address.";
if (v.patternMismatch) {
// The address parses, but our corporate-domain rule rejected it.
return "Use your @acme.com company address.";
}
if (v.tooLong) return "Email addresses are capped at 254 characters.";
return "That email can't be used.";
}
// Surface the message without the native bubble, keeping the
// same ValidityState the browser already computed.
const email = document.querySelector<HTMLInputElement>("#email")!;
const region = document.querySelector<HTMLElement>("#email-error")!;
email.addEventListener("input", () => {
const message = emailErrorFor(email);
region.textContent = message ?? "";
email.setAttribute("aria-invalid", message ? "true" : "false");
});
The branch order is deliberate: valueMissing and typeMismatch are mutually exclusive against patternMismatch in practice, but checking presence and native shape first produces the message a user can act on soonest. For the full flag catalogue and how each maps to a message, see Reading ValidityState Flags.
Frequently Asked Questions
Does type="email" require a dot in the domain?
No. The HTML spec's built-in definition accepts an address like user@localhost with no dot, because intranet hosts are legal. If you need a top-level domain, add a pattern such as [^@\s]+@[^@\s]+\.[^@\s]+, and the browser will require both the native shape and your pattern to pass.
How do I let a user enter several addresses in one field?
Add the boolean multiple attribute. The control then accepts a comma-separated list and validates each address independently, marking the whole field invalid if any one entry is malformed. Combine it with required if at least one address must be present.
Why does my valid email still fail validation?
Almost always an over-strict pattern. Inspect input.validity.patternMismatch in DevTools; if it is true, your regex is rejecting a legal address such as one using plus-addressing or a multi-label domain. Loosen the pattern to the permissive baseline rather than enumerating every TLD.
Does maxlength stop a user from typing past 254 characters?
Yes for typing, but not for pasting or programmatic assignment. maxlength is a hard cap on interactive keyboard entry, so the control simply refuses further keystrokes. A value set via input.value = … in script bypasses it entirely and does not raise tooLong — that flag only appears when the user edits an over-length value the browser did not itself insert. Re-validate on the server for the 254-character RFC ceiling; never treat maxlength as a security boundary.
Why doesn't inputmode="email" change what counts as valid?
inputmode is purely a presentation hint: it tells a touch device to show a keyboard with the @ and . keys surfaced, which reduces typos at the source. Validity is governed only by type, required, pattern, and the length attributes. Keep type="email" for the constraint and add inputmode="email" alongside it purely for the on-screen keyboard — the two are complementary, not interchangeable.
Related Guides
- HTML5 Pattern Attribute Regex Examples — copy-ready regexes for tightening the accepted email shape.
- Reading ValidityState Flags — map
typeMismatchandpatternMismatchto targeted messages. - How to Use setCustomValidity Correctly — replace the default bubble text without breaking native rules.
- Prevent Default Submission Without Losing Validation — wire the
reportValidity()gate into a real submit handler. - Constraint Validation API Deep Dive — the full model behind every attribute in this recipe.