Accessible error messages and validation

Form validation errors are one of the most frustrating experiences for screen reader users. When errors appear visually but aren’t announced, users submit forms repeatedly without understanding why they fail.

Associating errors with inputs

Every error message must be programmatically linked to its input using aria-describedby :

<label for="email">Email address</label>
<input 
    type="email" 
    id="email" 
    aria-describedby="email-error"
    aria-invalid="true"
/>
<span id="email-error" role="alert">
    Please enter a valid email address.
</span>

When the screen reader focuses the input, it announces : “Email address, edit, invalid entry, Please enter a valid email address.”

aria-invalid

Use aria-invalid to mark fields that have validation errors :

function validateField(input, errorEl) {
    if (!input.validity.valid) {
        input.setAttribute('aria-invalid', 'true');
        errorEl.textContent = input.validationMessage;
    } else {
        input.removeAttribute('aria-invalid');
        errorEl.textContent = '';
    }
}
ValueMeaning
trueThe value is invalid
falseThe value has been checked and is valid
grammarA grammatical error was detected
spellingA spelling error was detected

Live region announcements

Use role="alert" or aria-live="assertive" to announce errors immediately :

<div role="alert" id="form-errors" aria-atomic="true"></div>
function showErrors(errors) {
    const container = document.getElementById('form-errors');
    container.innerHTML = '';
    
    if (errors.length > 0) {
        const summary = document.createElement('p');
        summary.textContent = `${errors.length} error(s) found:`;
        container.appendChild(summary);

        const list = document.createElement('ul');
        errors.forEach(error => {
            const li = document.createElement('li');
            const link = document.createElement('a');
            link.href = `#${error.fieldId}`;
            link.textContent = error.message;
            li.appendChild(link);
            list.appendChild(li);
        });
        container.appendChild(list);
    }
}

Error summary pattern

For forms with multiple errors, show a summary at the top and link each error to its field :

<div role="alert" class="error-summary">
    <h2>There are 2 problems with your submission</h2>
    <ul>
        <li><a href="#name">Name is required</a></li>
        <li><a href="#email">Email format is invalid</a></li>
    </ul>
</div>

<form>
    <label for="name">Full name</label>
    <input type="text" id="name" aria-invalid="true" aria-describedby="name-error" />
    <span id="name-error" class="field-error">Name is required</span>

    <label for="email">Email</label>
    <input type="email" id="email" aria-invalid="true" aria-describedby="email-error" />
    <span id="email-error" class="field-error">Email format is invalid</span>
</form>

Move focus to the error summary after submission :

form.addEventListener('submit', (e) => {
    e.preventDefault();
    const errors = validate(form);
    
    if (errors.length > 0) {
        showErrorSummary(errors);
        const summary = document.querySelector('.error-summary');
        summary.setAttribute('tabindex', '-1');
        summary.focus();
    }
});

Inline validation timing

Validating while the user is still typing is disruptive. Follow this pattern :

  1. On submit — Always validate all fields
  2. On blur — Validate individual fields after the user leaves them
  3. On input (after error) — Once a field has an error, re-validate on each keystroke to clear the error as soon as it’s fixed
input.addEventListener('blur', () => {
    validateField(input, errorEl);
});

input.addEventListener('input', () => {
    if (input.getAttribute('aria-invalid') === 'true') {
        validateField(input, errorEl);
    }
});

Styling error states

input[aria-invalid="true"] {
    border-color: #ef4444;
    box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}

.field-error {
    color: #ef4444;
    font-size: 0.875rem;
    margin-top: 0.25rem;
    display: flex;
    align-items: center;
    gap: 0.25rem;
}

.field-error::before {
    content: '⚠';
}

.error-summary {
    border: 2px solid #ef4444;
    border-radius: 0.5rem;
    padding: 1rem 1.5rem;
    margin-bottom: 1.5rem;
    background: rgba(239, 68, 68, 0.05);
}

Important : Never use color alone to indicate errors. Combine color with icons, text, and borders so color-blind users can identify error states.

Common mistakes

  • Only showing a red border — Without text, screen readers have no idea what’s wrong.
  • Removing error messages on focus — Users need to read the error while fixing the field.
  • Clearing the entire form on error — Users lose all their progress.
  • Generic error messages — “Invalid input” doesn’t help. Say what’s wrong and how to fix it.
  • Missing aria-invalid — Screen readers use this to announce the error state.

Resources

If you found this helpful, share it with someone who's building for the web.

- Vinay Ranjan