When to Use a Toast vs an Inline Validation Error
Use an inline message for any error tied to a specific field, and reserve toasts for transient, operation-level status that is not bound to a single input — that one rule resolves the overwhelming majority of “toast or inline?” decisions without further thought.
The temptation to reach for a toast is strong because toasts are easy to fire and feel modern, but applying them to validation errors quietly breaks the experience: the message vanishes before the user finishes reading, it is detached from the field that caused it, and screen reader users may never hear it. This recipe gives you a sharp decision boundary and the small amount of code needed on each side of it.
When to Use This Recipe
Apply this decision the moment your form needs to communicate anything back to the user beyond the happy path:
- A field fails a constraint (required, format, length, mismatch) → inline, always.
- The submission itself succeeds, fails on the network, or hits a server error → toast.
- A background autosave completes or fails → toast.
- The action cannot continue at all (session expired, destructive confirmation) → not a toast; that is a modal, covered in the parent comparison of inline, toast, and modal delivery.
Why Validation Errors Are Almost Always Inline
Three properties of a validation error make a toast the wrong container for it:
- It is field-specific. “Email is required” only makes sense next to the email field. A toast floats in a corner with no spatial connection to the input, forcing the user to hunt for the offending field.
- It must persist until fixed. Constraint failures are not momentary status — they remain true until the user corrects the value. A toast that auto-dismisses after a few seconds removes the message while the problem still exists.
- It must be programmatically associated. Screen readers announce inline errors through
aria-describedbywhen focus reaches the field. A toast is a detached live-region announcement with no binding to the input, so a user tabbing to the field later hears nothing about why it is invalid.
Inline delivery satisfies all three, which is why it is the canonical surface in inline error messaging strategies and the default everywhere on this site.
Inline Side: Field-Bound Error
<div class="form-field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error" />
<p id="email-error" class="field-error" role="status" aria-live="polite" hidden></p>
</div>
function setInlineError(input: HTMLInputElement, message: string | null): void {
const el = document.getElementById(`${input.name}-error`);
if (!el) return;
input.setAttribute("aria-invalid", String(message !== null));
el.textContent = message ?? "";
el.hidden = message === null;
}
// Manual reporting on submit (house style: <form novalidate>).
form.addEventListener("submit", (event) => {
event.preventDefault();
const email = form.elements.namedItem("email") as HTMLInputElement;
if (!email.checkValidity()) {
setInlineError(email, email.validationMessage); // stays until corrected
email.focus();
return;
}
setInlineError(email, null);
void submit(new FormData(form));
});
Toast Side: Transient Submission Status
The toast appears only after inline validation has passed and the request has actually run — it reports the outcome of the operation, never a field constraint.
<!-- Persistent region, present from first paint so announcements work -->
<div id="toast-region" class="toast-region" role="status" aria-live="polite"></div>
function showToast(message: string, tone: "success" | "error"): void {
const region = document.getElementById("toast-region")!;
region.setAttribute("aria-live", tone === "error" ? "assertive" : "polite");
const toast = document.createElement("div");
toast.className = `toast toast--${tone}`;
toast.textContent = message;
region.append(toast);
window.setTimeout(() => toast.remove(), 5000); // transient by design
}
async function submit(data: FormData): Promise<void> {
try {
const res = await fetch("/api/subscribe", { method: "POST", body: data });
if (!res.ok) throw new Error(String(res.status));
showToast("You're subscribed.", "success"); // operation succeeded
} catch {
showToast("Network error — please retry.", "error"); // operation failed, not a field
}
}
Option Reference
| Signal | Channel | Live region / binding | Persistence |
|---|---|---|---|
| Required / format / length failure | Inline | aria-describedby + aria-invalid |
Until corrected |
| Cross-field mismatch (e.g. passwords) | Inline | aria-describedby on the second field |
Until corrected |
| Submission succeeded | Toast | role="status" (polite) |
Auto-dismiss ~5s |
| Network / server failure on submit | Toast | role="status" set assertive |
Auto-dismiss or sticky |
| Background autosave result | Toast | role="status" (polite) |
Auto-dismiss ~5s |
| Session expired / destructive confirm | Modal | role="alertdialog" |
Until dismissed |
Verification Steps
Edge Cases & Failure Modes
Submit fails because a field is invalid. Do not surface that as a toast. If submission is blocked by a field constraint, render the inline error and move focus to the field. Toasts are only for failures that are not attributable to a specific input.
The same message in both places. Echoing an error inline and in a toast makes screen readers announce it twice and clutters the UI. Choose one channel per message.
Assertive toast that auto-dismisses too fast. An assertive error toast can disappear before a screen reader finishes reading it. For error toasts, lengthen the timeout or make them sticky with a manual close button.
Deduplicating Toasts on Repeated Submit Failures
A single live region plus an auto-firing toast has one failure mode that inline errors never hit: repetition. If the network is flaky and the user taps submit three times, showToast stacks three identical “Network error” toasts and the aria-live region announces the same sentence three times in a row. That is noise, not information. Because the toast channel is the only place submission failures surface, it has to defend itself against duplicates — inline errors are naturally idempotent since each field owns exactly one message node.
Key each toast by its message and replace an existing one instead of appending a duplicate. Resetting the timer on collision keeps the most recent attempt visible for the full duration without letting the stack grow:
const activeToasts = new Map<string, { node: HTMLElement; timer: number }>();
function showToastDeduped(message: string, tone: "success" | "error"): void {
const region = document.getElementById("toast-region")!;
region.setAttribute("aria-live", tone === "error" ? "assertive" : "polite");
const existing = activeToasts.get(message);
if (existing) {
// Same failure again: reset the dismiss timer, do not re-announce or re-stack.
window.clearTimeout(existing.timer);
existing.timer = window.setTimeout(() => dismiss(message), 5000);
return;
}
const node = document.createElement("div");
node.className = `toast toast--${tone}`;
node.textContent = message;
region.append(node);
const timer = window.setTimeout(() => dismiss(message), 5000);
activeToasts.set(message, { node, timer });
}
function dismiss(message: string): void {
const entry = activeToasts.get(message);
if (!entry) return;
window.clearTimeout(entry.timer);
entry.node.remove();
activeToasts.delete(message);
}
The keying strategy matters. Keying on the exact string collapses byte-identical messages, which is what you want for a fixed “Network error — please retry.” string. If your copy interpolates a request ID or timestamp, key on a stable category instead ("network", "server-5xx") so genuinely repeated failures still collapse into one toast rather than slipping past the deduplication because their text differs.
Escalating Many Inline Errors to a Summary, Not a Toast
When a long form fails validation on submit with several fields at once, the instinct to fire a toast — “Please fix the errors below” — is understandable but wrong. The toast points nowhere, dismisses itself, and leaves a keyboard user to hunt down each invalid field. The accessible escalation is an error summary: a focusable region at the top of the form that lists each failure as an in-page anchor to the offending input. This is the pattern the UK GOV.UK Design System popularised, and it complements inline errors rather than replacing them — every field still keeps its own aria-describedby message.
function renderErrorSummary(fields: HTMLInputElement[]): void {
const invalid = fields.filter((f) => !f.checkValidity());
const summary = document.getElementById("error-summary") as HTMLElement;
if (invalid.length === 0) {
summary.hidden = true;
return;
}
summary.innerHTML =
`<h2>There is a problem</h2><ul>` +
invalid
.map((f) => `<li><a href="#${f.id}">${f.labels?.[0]?.textContent}</a></li>`)
.join("") +
`</ul>`;
summary.hidden = false;
summary.setAttribute("tabindex", "-1");
summary.focus(); // move focus so a screen reader reads the list immediately
}
The summary is a container with tabindex="-1" so you can move focus to it programmatically; that focus move is what makes a screen reader announce the whole list on submit, without needing a live region at all. Each anchor jumps to a field, and because the field already carries its inline message, the user lands exactly where the fix belongs. A toast could never do any of this — it is not focusable, not navigable, and not persistent. Reserve the toast, as always, for the one thing left over: an operation-level failure that no field can explain.
Frequently Asked Questions
Can I ever show a validation error in a toast?
Practically never. A validation error is field-specific and must persist until corrected, while a toast is detached and transient — a mismatch on both counts. The only time a toast carries submission-related text is when the operation fails for a reason unrelated to any single field, such as a network timeout. The field constraint itself stays inline.
What if my form is so short that an inline message feels heavy?
Inline is still correct. Even a single-field form benefits from a persistent, field-bound message
that screen readers can associate via aria-describedby. Pre-render the container with
hidden so it adds no layout shift when empty. The perceived weight comes from styling, not
from the channel — keep the message compact rather than switching to a toast.
Where does a "saved successfully" message go?
A toast. Success confirmation is transient, operation-level status not tied to any field, which is
exactly what a polite role="status" toast is for. Let it auto-dismiss after a few seconds.
If saving fails for a field-specific reason, render that part inline on the relevant field rather than in
the toast.
Should a whole-form "please fix the errors" message be a toast?
No — use a focusable error summary at the top of the form, not a toast. A summary lists each failing field as an in-page anchor and persists until the errors are resolved, so a keyboard user can jump straight to each fix. A toast is not focusable and auto-dismisses, so it strands the user with no way back to the offending inputs. Keep the per-field inline messages in place as well; the summary links to them, it does not replace them.
Why do repeated network errors announce multiple times, and how do I stop it?
Each new node appended to an aria-live region is announced independently, so three failed
submits produce three announcements of the same sentence. Deduplicate by keying toasts on their message
(or a stable category like "network") and, on a repeat, resetting the existing toast's
dismiss timer instead of appending a new node. The user then hears the failure once and sees a single
toast that stays visible for the full duration of the most recent attempt.
Related Guides
- Inline vs Toast vs Modal Error Delivery — the full comparison this recipe is drawn from, including modals.
- Inline Error Messaging Strategies — structuring the field-bound messages that handle every validation error.
- Designing Accessible Error Toast Notifications — building the toast side accessibly once you have decided it belongs.