The Problem
Marking required fields with an error state immediately upon page load can be confusing. Ideally, a required field should only look "invalid" if the user has attempted to fill it out and failed.
The Solution
The :user-invalid pseudo-class solves this perfectly. For a required field, it will not match on page load. It will only match if:
- The user interacts with the field (e.g., types a character and deletes it) and then leaves it (blur), leaving it empty.
- The user attempts to submit the form while the field is empty.
Implementation Strategy
- HTML Constraint: Add the
requiredattribute to your inputs. - Visual Feedback: Use
:user-invalidto style the border red and show a "Required" helper text. - Timing: Rely on the browser's native timing for visual feedback. You don't need
onBlurhandlers to add atouchedclass anymore, though some JavaScript is still needed to sync ARIA attributes (see below). - Error Text: Show visible error text with the error styling, so the error isn't shown by color alone. Start each message with an error icon that has a text alternative such as "Error" (the safest choice), or word every message as an error, for example "Name is required.", and use the same approach for every error in the form.
- Error Link: List the error message in the field's
aria-describedbyonly while the field is invalid, so screen readers read it with the field. A reference in the markup would read the hidden message from page load. The script in section 3 does this for each field whosedata-errorattribute is set to its error message'sid.
Implementation Guide
1. HTML Structure
<form id="feedback-form">
<div class="field">
<label for="full-name">Full Name</label>
<!-- Set data-error to the error message's id. The aria-invalid script lists that id in aria-describedby while the field is invalid. -->
<input
type="text"
id="full-name"
name="full-name"
required
data-error="name-error"
>
<!-- MANDATORY: Include an icon or distinct non-color indicator alongside error text -->
<div id="name-error" class="error-msg">
<span role="img" aria-label="Error">❌</span> This field is required.
</div>
</div>
</form>
2. CSS
.error-msg {
display: none;
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/*
Only highlight empty required fields AFTER the user interacts with them or attempts submission.
MANDATORY: Provide multiple indicators (border shift + helper text/icon) to avoid color-only state communication.
*/
input:user-invalid {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg {
display: block;
}
/* Optional: Subtle indicator for required fields that are valid */
input:required:user-valid {
border-color: #188038;
border-width: 2px;
}
3. JavaScript State Synchronization
MANDATORY: Because :user-invalid is a visual state, you MUST provide a JavaScript bridge to sync aria-invalid="true" dynamically for assistive technologies when a user blurs an invalid field or attempts submission, deriving it from :user-invalid, not checkValidity(), which is already false when a user tabs through an untouched field.
Native :user-invalid does not automatically sync with ARIA attributes. Add the following JavaScript to keep aria-invalid in sync with the visual state:
- Set
aria-invalid="false"until:user-invalidmatches, rather than leaving it unset. Firefox and Safari expose an emptyrequiredfield as invalid from page load, and Chromium doesn't expose an emptyrequiredfield as invalid after interaction unlessaria-invalid="true"is set. - Link each field to its error message by setting the field's
data-errorattribute to the error message'sid(data-error="email-error"for<div id="email-error">), and let the script list thatidinaria-describedbyonly while the field is invalid. Don't reference the error fromaria-describedbyin the markup: the error element is always in the page, and text thataria-describedbyreferences is announced even when it's hidden. - Announce an error that appears when the user leaves or changes a field through one polite live region for the page, a moment later so it doesn't talk over the next field. Don't make each error message a live region: a live region that is shown, rather than changed, isn't announced reliably. After a failed submit, move focus to the first invalid field so that its error is announced instead.
// List the field's error message (the id in data-error) in aria-describedby only while
// it's shown: text that aria-describedby references is announced even when it's hidden.
const linkError = (el, shown) => {
const errorId = el.dataset.error;
if (!errorId) return;
const ids = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter((id) => id && id !== errorId);
if (shown) ids.push(errorId);
if (ids.length) el.setAttribute('aria-describedby', ids.join(' '));
else el.removeAttribute('aria-describedby');
};
// One polite live region for the page announces an error that appears when the user
// leaves or changes a field. Run this once the form is in the page.
const announcer = document.createElement('div');
announcer.setAttribute('aria-live', 'polite');
announcer.classList.add('visually-hidden');
document.body.append(announcer);
let announceTimer;
let announcedField;
// Text as a screen reader reads it: icons' text alternatives in, aria-hidden text out.
const spokenText = (node) => {
const copy = node.cloneNode(true);
copy.querySelectorAll('[aria-hidden="true"]').forEach((n) => n.remove());
copy.querySelectorAll('[role="img"][aria-label]').forEach((n) => n.replaceWith(`${n.getAttribute('aria-label')}: `));
return copy.textContent.replace(/\s+/g, ' ').trim();
};
const announceError = (el) => {
const error = document.getElementById(el.dataset.error);
if (!error) return;
const label = el.labels?.[0] ? `${spokenText(el.labels[0])}: ` : '';
clearTimeout(announceTimer);
announcer.textContent = '';
announcedField = el;
// Wait until the screen reader has announced the field that receives focus next.
announceTimer = setTimeout(() => { announcer.textContent = label + spokenText(error); }, 500);
};
// Sync aria-invalid with the CSS :user-invalid state. Only form fields are synced, and
// an aria-invalid="true" that this script didn't set (for example from a server check)
// is left alone.
const flagged = new WeakSet();
// Fields whose current error has been announced, or read out by focus after a failed
// submit. A field can become invalid while the user types, so this is tracked separately.
const announced = new WeakSet();
const syncAria = (el, announce = false) => {
if (!el.matches?.('input, select, textarea')) return;
const invalid = el.matches(':user-invalid');
if (invalid) {
if (announce && !announced.has(el)) {
announceError(el);
announced.add(el);
}
el.setAttribute('aria-invalid', 'true');
linkError(el, true);
flagged.add(el);
} else if (flagged.has(el) || el.getAttribute('aria-invalid') !== 'true') {
el.setAttribute('aria-invalid', 'false');
linkError(el, false);
flagged.delete(el);
announced.delete(el);
// Clear a fixed field's announcement so it isn't found later when reading the page.
if (announcedField === el) {
clearTimeout(announceTimer);
announcer.textContent = '';
}
}
};
// Start every field at aria-invalid="false".
document.querySelectorAll('input, select, textarea').forEach((el) => syncAria(el));
// Update on blur (to show and announce an error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target, true), true);
document.addEventListener('input', (e) => {
if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});
// Checkboxes, radio buttons and other controls that commit on 'change' can become
// :user-invalid without losing focus, so update on change too.
document.addEventListener('change', (e) => syncAria(e.target, true), true);
// A failed submit fires 'invalid' on each failing field, including required fields the
// user never touched, so update those too. 'invalid' doesn't bubble. Focus moving to the
// first invalid field announces its error, so these aren't announced.
document.addEventListener('invalid', (e) => {
announced.add(e.target);
syncAria(e.target);
}, true);
// Reset clears :user-invalid after the 'reset' event, so update each field afterwards.
document.addEventListener('reset', (e) => {
setTimeout(() => [...e.target.elements].forEach((el) => syncAria(el)));
}, true);
Hide the live region with the .visually-hidden utility:
/* Hides content visually but keeps it in the accessibility tree */
.visually-hidden:where(:not(:focus-within, :active)) {
position: absolute !important;
clip-path: inset(50%) !important;
overflow: hidden !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
padding: 0 !important;
border: 0 !important;
white-space: nowrap !important;
}
Fallbacking & Browser Support
Fallbacks & browser support for :user-valid and :user-invalid
CSS for Fallback
/* Applies to every field that can be invalid (input, select, textarea). :is() keeps the
rule working where :user-invalid isn't supported: in a plain selector list, one
unsupported selector drops the whole rule. */
:is(:user-invalid, .user-invalid-fallback) {
border-color: #d93025;
background-color: #fce8e6;
}
:is(:user-invalid, .user-invalid-fallback) + .error-msg {
display: block;
}
JavaScript Fallback
Use a reusable utility that tracks interaction state using a WeakMap. This avoids polluting the DOM with "dirty" classes or data attributes.
const UserInvalidFallback = (() => {
const dirtyState = new WeakMap();
const updateState = (input) => {
// validity.valid gives the current state without firing an 'invalid' event, as checkValidity() does.
const isValid = input.validity.valid;
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
input.setAttribute('aria-invalid', isValid ? 'false' : 'true');
linkError(input, !isValid);
};
// List the error message (the id in data-error) in aria-describedby only while it's
// shown: text that aria-describedby references is announced even when it's hidden.
const linkError = (input, shown) => {
const errorId = input.dataset.error;
if (!errorId) return;
const ids = (input.getAttribute('aria-describedby') || '').split(/\s+/).filter((id) => id && id !== errorId);
if (shown) ids.push(errorId);
if (ids.length) input.setAttribute('aria-describedby', ids.join(' '));
else input.removeAttribute('aria-describedby');
};
const handleEvent = (event) => {
const input = event.target;
if (event.type === 'reset') {
const controls = input.elements || [];
for (const control of controls) {
dirtyState.delete(control);
control.classList.remove('user-invalid-fallback');
control.classList.remove('user-valid-fallback');
if (control.matches('input, select, textarea')) control.setAttribute('aria-invalid', 'false');
if (control.dataset?.error) linkError(control, false);
}
return;
}
if (!input.checkValidity) return;
// A submit attempt counts as interaction: the browser fires 'invalid' on each failing
// field, including required fields the user never touched, so flag them all.
if (event.type === 'invalid') {
dirtyState.set(input, { hasInteracted: true, hasBlurred: true });
updateState(input);
return;
}
if (event.type === 'input' || event.type === 'change') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasInteracted = true;
dirtyState.set(input, state);
if (state.hasBlurred) {
updateState(input);
}
} else if (event.type === 'blur') {
const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
state.hasBlurred = true;
dirtyState.set(input, state);
if (state.hasInteracted) {
updateState(input);
}
}
};
const init = (root = document) => {
if (CSS.supports('selector(:user-invalid)')) return;
// Firefox and Safari expose an empty required field as invalid from page load.
root.querySelectorAll('input, select, textarea').forEach((el) => {
if (!el.hasAttribute('aria-invalid')) el.setAttribute('aria-invalid', 'false');
});
root.addEventListener('blur', handleEvent, true); // Capture phase
root.addEventListener('input', handleEvent);
root.addEventListener('change', handleEvent);
root.addEventListener('invalid', handleEvent, true); // invalid doesn't bubble
root.addEventListener('reset', handleEvent, true); // Capture resets
};
return { init };
})();
// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);
Other Considerations
- Asterisks: It is still best practice to indicate required fields visually (e.g., with an asterisk
*) in the label, so users know what to expect before they interact. Explain the asterisk once before the form (for example, "Fields marked * are required."), and wrap it in<span aria-hidden="true">*</span>, because therequiredattribute already makes screen readers announce "required". - Submit Buttons: Unlike
disabledbuttons, keep your submit button enabled. If the user clicks it, the browser will automatically trigger:user-invalidon all empty required fields and focus the first one. This is excellent for accessibility and UX.