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).
Implementation Guide
1. HTML Structure
<form id="feedback-form">
<div class="field">
<label for="full-name">Full Name</label>
<input
type="text"
id="full-name"
name="full-name"
required
aria-errormessage="name-error"
>
<!-- MANDATORY: Include an icon or distinct non-color indicator alongside error text -->
<div id="name-error" class="error-msg">
<span aria-hidden="true">❌</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 visits them.
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.
const form = document.getElementById('feedback-form');
const syncAriaInvalid = (input) => {
if (!input.checkValidity()) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
// Sync on blur when a user finishes interacting
form.addEventListener('blur', (e) => {
if (e.target.matches('input[required]')) {
syncAriaInvalid(e.target);
}
}, true);
// Sync all required fields when submission is attempted
form.addEventListener('submit', () => {
form.querySelectorAll('input[required]').forEach(syncAriaInvalid);
});
// Remove error state immediately upon correction
form.addEventListener('input', (e) => {
if (e.target.matches('input[required]') && e.target.checkValidity()) {
e.target.removeAttribute('aria-invalid');
}
});
Fallbacking & Browser Support
Fallbacks & browser support for :user-valid and :user-invalid
CSS for Fallback
input:user-invalid,
input.user-invalid-fallback {
border-color: #d93025;
background-color: #fce8e6;
}
input:user-invalid + .error-msg,
input.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) => {
const isValid = input.checkValidity();
// Update both visual and ARIA state
input.classList.toggle('user-invalid-fallback', !isValid);
input.classList.toggle('user-valid-fallback', isValid);
if (!isValid) {
input.setAttribute('aria-invalid', 'true');
} else {
input.removeAttribute('aria-invalid');
}
};
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');
control.removeAttribute('aria-invalid');
}
return;
}
if (!input.checkValidity) 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;
root.addEventListener('blur', handleEvent, true); // Capture phase
root.addEventListener('input', handleEvent);
root.addEventListener('change', handleEvent);
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. - 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. - Accessibility: Native
:user-invaliddoes not automatically sync with ARIA attributes. Add the following JavaScript to keeparia-invalidin sync with the visual state:
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};
// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});