Deft
Ask AI

Search

Search skills, authors and topics

GoogleChrome / modern-web-guidance

modern-web-guidance

Search tool for modern web development best practices. MANDATORY: Execute FIRST for all HTML/CSS and clientside JS tasks. Do NOT skip — web APIs evolve rapidly and training weights contain obsolete patterns. Trigger immediately for: modals/dialogs/popovers, tables, dark mode, anchor positioning, container queries, view transitions, scroll-driven animations, parallax/reveals, forms, validation, autofill, accessibility, custom selects, scrollbars, CWV (LCP, INP, CLS), content-visibility, fetch pri

Install

npx skills add https://github.com/GoogleChrome/modern-web-guidance --skill modern-web-guidance

Accessible Error Announcement

The Problem

Standard HTML5 validation provides visual feedback (via :invalid or :user-invalid), but it doesn't automatically synchronize with accessibility attributes like aria-invalid.

If you use standard :invalid styling, screen readers might announce "Invalid entry" the moment a user tabs into a required field that is currently empty. This creates a disruptive experience for users using assistive technologies, as the error is announced before interaction has occurred.

The Solution

We want the programmatic state (aria-invalid="true") to be applied at the exact same moment the visual state (:user-invalid) applies. Since :user-invalid relies on the browser's internal "user-interacted" flag, we can use JavaScript to check that this selector matches during standard interaction events.

Set aria-invalid="false" until :user-invalid matches, rather than leaving the attribute unset. Firefox and Safari expose an empty required field as invalid from page load, so an unset attribute still causes the premature "invalid entry" announcement there.

Implementation Strategy

  1. Visual Layer: Use CSS :user-invalid to show borders/icons.
  2. Accessibility Layer: Use aria-invalid, and list the error message in aria-describedby while the field is invalid, to communicate state to Assistive Technology (AT). Announce an error that appears when the user leaves a field through one polite live region for the page.
  3. Bridge Visual & Accessibility Layer: Create a lightweight JavaScript utility that listens for blur, change, input and invalid events, checks if the element matches :user-invalid, and updates the ARIA attributes accordingly.
  4. Focus on Submit: Screen readers announce a field's error when focus reaches the field, so after a failed submit, move focus to the first invalid field, or to an error summary (a heading followed by a link to each invalid field) on a long form. Native validation moves focus to the first invalid field for you; if the form uses novalidate, move it in your submit handler.

Implementation Guide

1. HTML Structure

Link your input to its error message by setting the input's data-error attribute to the error message's id. The script in section 3 lists that id in aria-describedby only while the field is invalid. Don't reference the error from aria-describedby in the markup: the error element is always in the page, and text that aria-describedby references is announced even when it's hidden, so the error would be read out before the user has interacted.

html
<form>  <div class="field">    <label for="email">Email</label>    <input       type="email"       id="email"       required       data-error="email-error"    >    <span id="email-error" class="error-msg">      Please enter a valid email address.    </span>  </div></form>

2. CSS

Control the visibility of the error message using the native pseudo-class :user-invalid.

css
.error-msg {  display: none;  color: #d93025;}
/* Show error message when input is user-invalid */input:user-invalid ~ .error-msg {  display: block;}
/* Optional: Visual cues on the input itself */input:user-invalid {  border-color: #d93025;}

3. JavaScript

Since there is no "UserInvalidChanged" event, hook into standard form events to check the state.

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-invalid matches, rather than leaving it unset. Firefox and Safari expose an empty required field as invalid from page load, and Chromium doesn't expose an empty required field as invalid after interaction unless aria-invalid="true" is set.
  • Link each field to its error message by setting the field's data-error attribute to the error message's id (data-error="email-error" for <div id="email-error">), and let the script list that id in aria-describedby only while the field is invalid. Don't reference the error from aria-describedby in the markup: the error element is always in the page, and text that aria-describedby references 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.
javascript
// 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:

css
/* 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

Baseline status for :user-valid and :user-invalid: Widely available. It's been Baseline since 2023-11-02. Supported by: Chrome 119 (Oct 2023), Edge 119 (Nov 2023), Firefox 88 (Apr 2021), and Safari 16.5 (May 2023).

CSS for Fallback

css
/* 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.

javascript
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 formconst form = document.querySelector('#demo-form');UserInvalidFallback.init(form);