Writing Clear, Actionable Inline Error Message Copy
An inline error that reads “Invalid input” tells the user nothing they can act on; this page shows how to author error copy that names the constraint that failed and the exact correction, then wire that copy into the native Constraint Validation API so the same string drives both the accessibility tree and the visible message.
When to Use This String-Authoring Approach
This recipe is about the words and where they live, not about when the message appears. It assumes you have already decided your validation timing and delivery surface elsewhere:
- Reach for this when the message renders next to the field it describes and must persist until the value becomes valid. If you are deciding between an adjacent message and a transient banner, settle that first in When to Use a Toast vs an Inline Error.
- Use it when copy needs to be specific to the failing constraint (too short, wrong format, already taken) rather than a single generic sentence per field.
- Pair it with Best Practices for Inline Validation Timing so the well-authored string is not fired on every keystroke before the user has finished typing.
Deriving Copy from ValidityState Flags
The baseline pattern on this site ships <form novalidate> and drives validity through a single manual reportValidity() call. That gives you a hook to map each ValidityState flag to a purpose-written sentence instead of relying on the browser’s default text. The function below inspects which flag tripped and returns copy authored for that specific failure.
// One authored sentence per constraint the field can violate.
// Keys mirror the boolean flags on ValidityState.
type CopyMap = Partial<Record<keyof ValidityState, string>>;
function messageFor(input: HTMLInputElement, copy: CopyMap): string {
const v = input.validity;
// Order matters: report the most actionable failure first.
if (v.valueMissing && copy.valueMissing) return copy.valueMissing;
if (v.typeMismatch && copy.typeMismatch) return copy.typeMismatch;
if (v.tooShort && copy.tooShort) return copy.tooShort;
if (v.patternMismatch && copy.patternMismatch) return copy.patternMismatch;
// Fallback: never surface the raw browser string to users.
return copy.valueMissing ?? "Please review this field.";
}
const emailCopy: CopyMap = {
valueMissing: "Enter your work email so we can send the receipt.",
typeMismatch: "That email is missing an @ - check for a typo.",
};
const form = document.querySelector<HTMLFormElement>("#signup")!;
const email = form.elements.namedItem("email") as HTMLInputElement;
const errorEl = document.querySelector<HTMLElement>("#email-error")!;
form.addEventListener("submit", (e) => {
// novalidate means the browser will not block submit for us.
if (!form.checkValidity()) {
e.preventDefault();
if (!email.validity.valid) {
const text = messageFor(email, emailCopy);
email.setCustomValidity(text); // feeds native reportValidity + a11y
errorEl.textContent = text; // visible message, same string
email.setAttribute("aria-invalid", "true");
email.setAttribute("aria-describedby", "email-error");
}
form.reportValidity(); // single manual entry point
}
});
// Clear the message the moment the field becomes valid again.
email.addEventListener("input", () => {
if (email.validity.valid) {
email.setCustomValidity("");
errorEl.textContent = "";
email.removeAttribute("aria-invalid");
}
});
The same authored string flows into setCustomValidity() (so the native bubble and screen readers agree) and into the visible #email-error node. Writing it once removes the drift where the spoken message and the seen message diverge. For richer, non-field-specific rules use custom validity messages built on the same call.
Copy Authoring: Option Reference
These are the knobs that shape a single message. Treat them as a checklist per string, not per field.
| Option | Type | Default | Purpose |
|---|---|---|---|
constraint |
keyof ValidityState |
valueMissing |
Which failure this sentence describes; one string per flag. |
voice |
"imperative" | "descriptive" |
imperative |
“Enter a date” beats “Date is required” for actionability. |
namesFix |
boolean |
true |
Whether the sentence states the correction, not just the problem. |
echoesValue |
boolean |
false |
Whether to quote the bad input back (“.co is not a valid domain”). |
maxWords |
number |
12 |
Keeps the message scannable next to the input. |
tone |
"neutral" | "apologetic" |
neutral |
Avoid blame (“You entered”) and apology (“Sorry”); state the fix. |
Verification Steps
import { test, expect } from "@playwright/test";
test("email copy names the fix, not just the fault", async ({ page }) => {
await page.goto("/signup");
await page.getByRole("button", { name: "Create account" }).click();
const err = page.locator("#email-error");
await expect(err).toHaveText(/Enter your work email/);
await page.getByLabel("Email").fill("nope");
await page.getByRole("button", { name: "Create account" }).click();
await expect(err).toHaveText(/missing an @/); // distinct constraint, distinct copy
});
Edge Cases & Failure Modes
Stale message after an async check resolves. When copy depends on asynchronous server checks such as “This username is taken”, a slow response can overwrite copy for a value the user has already changed. Guard by discarding any resolved check whose input value no longer matches the one that was submitted, and clear setCustomValidity("") before firing a fresh request.
Message text and screen-reader text diverge. If you set errorEl.textContent but forget setCustomValidity() (or vice versa), sighted and non-sighted users get different copy. Always write both from one variable, as the implementation above does, and cover it with axe-core accessibility testing.
Schema messages leak framework jargon. Copy generated by Zod schema validation often reads “String must contain at least 8 character(s)”. Map each schema issue code to a hand-authored sentence before it reaches the DOM, the same way messageFor maps ValidityState flags.
Parameterizing Copy for rangeUnderflow and tooShort
Range and length constraints are the two cases where a static sentence leaks the wrong number. tooShort fires against minlength, and rangeUnderflow/rangeOverflow fire against min/max, so the actionable figure the user needs is already sitting on the input as an attribute or on the ValidityState-adjacent properties. Read it back rather than hard-coding it, and the copy stays correct when the constraint changes.
// Copy templates receive the live constraint values, so the sentence
// always quotes the same number the browser is enforcing.
type Template = (ctx: { value: string; min?: string; max?: string; minLength: number }) => string;
const templates: Partial<Record<keyof ValidityState, Template>> = {
tooShort: ({ minLength, value }) =>
// input.minLength is the enforced floor; value.length is where they are now.
`Use at least ${minLength} characters - you have ${value.length}.`,
rangeUnderflow: ({ min }) => `Enter a number of ${min} or more.`,
rangeOverflow: ({ max }) => `Enter a number of ${max} or less.`,
};
function templatedMessage(input: HTMLInputElement): string | null {
const v = input.validity;
const ctx = {
value: input.value,
min: input.min || undefined,
max: input.max || undefined,
minLength: input.minLength, // -1 when the attribute is absent
};
for (const flag of Object.keys(templates) as (keyof ValidityState)[]) {
if (v[flag] && templates[flag]) return templates[flag]!(ctx);
}
return null; // fall back to the static CopyMap for non-numeric failures
}
Because the number is pulled from input.minLength, input.min, and input.max at render time, editing the HTML constraint updates the message with no second edit. Reserve this template layer for constraints that carry a figure; keep flat strings for valueMissing and typeMismatch, where there is nothing to interpolate. When a single count needs pluralizing across locales, format it through new Intl.PluralRules(navigator.language) and select the phrase from a small { one, other } record rather than appending an "s", which breaks in most languages.
Echoing the Invalid Value Without Opening an Injection Hole
The echoesValue option in the reference table quotes the bad input back to the user, which sharpens copy like ".co" is not a complete domain. It is also the one authoring choice that touches security: the rejected value is attacker-controlled text, and reflecting it into the DOM is exactly the shape of a stored or reflected cross-site scripting bug. Assign it through textContent, never innerHTML, so the string is treated as data. If you must place it inside a larger fragment, build the node tree explicitly instead of concatenating a markup string.
function echoInvalid(errorEl: HTMLElement, message: string, badValue: string): void {
errorEl.textContent = ""; // clear prior copy
errorEl.append(message + " ");
const quoted = document.createElement("code");
quoted.textContent = badValue.slice(0, 40); // cap length; textContent escapes it
errorEl.append(quoted);
}
Truncate the echoed fragment - a pasted 4,000-character value should not reflow the whole form - and strip control characters if the value may contain them. The same rule applies when copy originates server-side: a message returned from an asynchronous server check that embeds the submitted value must be inserted as text, because the client cannot assume the server sanitized it. Treat every echoed byte as untrusted regardless of where the string was assembled.
Frequently Asked Questions
Should an inline error message start with the word "Error"?
No. The aria-invalid state and visual styling already signal that something failed, so the word "Error" wastes the first, most-scanned words of the sentence. Lead with the imperative correction instead, e.g. "Enter a date in the future".
Can I reuse the browser's default validation message as my copy?
Only as a last-resort fallback. Default strings like input.validationMessage vary by browser and locale and rarely name the fix. Author your own sentence and pass it through setCustomValidity() so the native bubble shows your text.
Where should the error text sit in the DOM relative to the input?
Immediately after the input, referenced by aria-describedby. This keeps the message in reading order for screen readers and lets you place focus on the field while its message is announced. See focus management after a failed submit for the ordering details.
Should the number in a "too short" message count spaces and emoji?
Match whatever the constraint counts. The minlength attribute measures UTF-16 code units, so value.length agrees with the browser's own tooShort decision and your copy stays consistent with it. Be aware this means a single emoji can count as two, so if your rule is meant to count user-perceived characters, enforce it with a custom check via Intl.Segmenter and author the message against that count instead.
Related Guides
- Inline Error Messaging Strategies — the parent overview covering accessible error messaging end to end.
- Best Practices for Inline Validation Timing — decide when your authored copy appears.
- When to Use a Toast vs an Inline Error — choose the surface before you write the string.
- Managing Focus After Validation Failure — make sure the message is reached, including under cross-field validation.