Inline Form Validation Inside Editable Table Cells
Permalink to "Inline Form Validation Inside Editable Table Cells"aria-invalid combined with aria-describedby is the baseline ARIA pair that makes cell-level validation accessible: aria-invalid="true" tells assistive technologies the input is in an error state, and aria-describedby associates the error text so it is announced when the field receives focus. Without both attributes, a screen reader user who tabs to an invalid cell hears no indication of the problem — a direct failure of WCAG 2.2 Success Criterion 3.3.1 (Error Identification, Level A).
Spec Reference
Permalink to "Spec Reference"| Source | Attribute / Criterion | Valid values | Default |
|---|---|---|---|
| ARIA 1.2 spec | aria-invalid |
false (default), true, grammar, spelling |
false |
| ARIA 1.2 spec | aria-describedby |
space-separated list of element IDs | — |
| ARIA 1.1 spec | aria-errormessage |
single element ID | — |
| WCAG 2.2 SC 3.3.1 (Level A) | Error Identification | Errors detected automatically must be described in text | — |
| WCAG 2.2 SC 3.3.3 (Level AA) | Error Suggestion | If an input error is detected and suggestions are known, they must be provided | — |
| WCAG 2.2 SC 1.4.1 (Level A) | Use of Color | Error state cannot be communicated by color alone | — |
aria-invalid="false" is the neutral state — it is equivalent to the attribute being absent. Set it explicitly on injected inputs so that toggling to "true" triggers screen reader announcements reliably. aria-errormessage is the ARIA 1.1 standard for pointing to error text, but AT support remains uneven; always pair it with aria-describedby pointing to the same element as a fallback.
When to Use vs. When Not to Use
Permalink to "When to Use vs. When Not to Use"Use aria-invalid + aria-describedby when:
- A native
<input>or<select>has been injected into arole="gridcell"for editing. - Validation has run and detected a specific, describable error.
- The error text is rendered in the DOM (even if visually hidden — display:none hides it from AT too).
Do not use aria-invalid="true" when:
- No validation has run yet — the attribute should not be set preemptively on empty fields the user has not touched.
- The field is in a read-only display state; the attribute applies to interactive controls only.
- You are using a native
<input type="email">or<input type="number">whose browser constraint validation is active — the browser sets the invalid state automatically via the:invalidCSS pseudo-class; addingaria-invalidon top creates duplicate announcements in some AT.
A common misapplication is toggling aria-invalid on the <td> cell element rather than on the <input> inside it. Screen readers track the invalid state on the focusable control, not its container. Placing it on the <td> silently does nothing in NVDA, JAWS, and VoiceOver.
Annotated Code Example
Permalink to "Annotated Code Example"The minimum viable implementation for one editable email cell:
<!-- SC 3.3.1: gridcell contains the interactive control -->
<div role="gridcell" data-field="email">
<!-- SC 3.3.1: aria-invalid signals error state to AT -->
<!-- SC 3.3.1: aria-describedby links input to error text -->
<!-- ARIA 1.1: aria-errormessage as primary link (fallback: aria-describedby) -->
<input
type="text"
class="cell-input"
id="cell-input-row3-email"
aria-label="Email address, row 3"
aria-invalid="true"
aria-describedby="cell-err-row3-email"
aria-errormessage="cell-err-row3-email"
value="not-an-email"
/>
<!-- role="alert": announces immediately when text is written in — SC 3.3.1 -->
<!-- aria-atomic="true": forces the full message to be read, not just the diff -->
<div
id="cell-err-row3-email"
role="alert"
aria-atomic="true"
class="cell-error"
>
<!-- SC 3.3.3: error suggestion tells the user the correct format -->
Invalid email address. Enter a complete address, for example name@domain.com.
</div>
</div>
And the JavaScript that sets and clears those states on blur:
function applyValidationResult(input, errorEl, errorMessage) {
if (errorMessage) {
// SC 3.3.1: mark the input invalid so AT announces on focus
input.setAttribute('aria-invalid', 'true');
// SC 3.3.1 + role="alert": writing to the container triggers immediate announcement
errorEl.textContent = errorMessage;
} else {
// Clear invalid state when the value passes validation
input.setAttribute('aria-invalid', 'false');
errorEl.textContent = '';
}
}
// Event delegation — one listener on the grid, not per-cell
const grid = document.querySelector('[role="grid"]');
// Synchronous format validation on blur (SC 3.3.1 does not require real-time checks)
grid.addEventListener('blur', (event) => {
if (!event.target.matches('.cell-input')) return;
const input = event.target;
const field = input.closest('[role="gridcell"]').dataset.field;
const errorEl = input.closest('[role="gridcell"]').querySelector('[role="alert"]');
const message = validateField(field, input.value); // returns string or null
applyValidationResult(input, errorEl, message);
}, /* useCapture */ true);
Validation State Machine Diagram
Permalink to "Validation State Machine Diagram"The diagram below maps the three cell states — read-only, editing, and error — and the transitions between them driven by keyboard events and validation outcomes.
Keyboard and AT Behaviour
Permalink to "Keyboard and AT Behaviour"| Key / Event | Expected AT Announcement | NVDA Deviation | VoiceOver Deviation |
|---|---|---|---|
Enter / F2 on read-only cell |
“Email address, row 3, edit, not invalid” | Announces role after field label | Announces “text field” type |
blur from input with error |
Silence (error already announced via role="alert") |
May repeat error from aria-describedby on re-focus |
Re-reads aria-describedby content on focus |
Enter to commit (fail) |
“[error message text]” via role="alert" immediately |
Announces alert inline with current speech | May queue alert after current utterance |
Escape to cancel |
Focus returns to cell; announces cell coordinates | Consistent | Consistent |
Tab away from ERROR cell |
Next cell announced; previous error state preserved in DOM | Consistent | Consistent |
Refocus ERROR cell (Shift+Tab) |
“Email address, row 3, invalid, [error text]” via aria-describedby |
Announces “invalid” before label | Announces “invalid data” after label |
Integration Context
Permalink to "Integration Context"Cell-level validation is one layer inside the wider inline editing and form controls pattern. That parent pattern governs how cells transition into and out of edit mode, how focus returns to the grid after a commit, and how role="gridcell" interacts with role="grid" to support arrow-key navigation. Validation state must not disrupt any of those mechanics.
For the announcement side, the role="alert" approach here uses the same assertive live region concept described in choosing between polite and assertive aria-live regions. Cell-level errors warrant assertive announcement on commit; incremental hints during typing should use a polite region so they do not interrupt navigation announcements.
When focus management in single-page apps is at play — for example, after a row-level save operation re-renders the table — ensure focus returns to the correct cell and that aria-invalid is rehydrated from your application state, not inferred from the DOM.
Gotchas
Permalink to "Gotchas"1. Writing to role="alert" before it is in the DOM
Permalink to "1. Writing to role="alert" before it is in the DOM" role="alert" only triggers an announcement when text is inserted into an element that is already in the DOM. A common mistake is injecting the entire <div role="alert"> with its text content in one operation — the browser may not fire the alert event because the element was not present during the previous render frame. Fix: insert the empty container first (on cell activation), then write the error text into it later when validation fails.
// WRONG: inserting the alert element with text simultaneously
cell.innerHTML = `<div role="alert">Invalid email.</div>`;
// CORRECT: container already in DOM (empty); write text when validation fails
const alertEl = cell.querySelector('[role="alert"]');
alertEl.textContent = 'Invalid email address. Enter a complete address.';
2. Losing validation state during virtualized grid scroll
Permalink to "2. Losing validation state during virtualized grid scroll"Virtualized grids recycle DOM nodes when rows scroll out of the viewport. Any aria-invalid state and error text written to a recycled node disappears. Store validation results in a Map keyed by rowId + columnId, and rehydrate during each render cycle:
// validation store keyed by "rowId:colId"
const validationStore = new Map();
function renderCell(rowId, colId, input, errorEl) {
const key = `${rowId}:${colId}`;
const result = validationStore.get(key);
if (result) {
// SC 3.3.1: rehydrate aria-invalid on recycled node
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = result.message;
} else {
input.setAttribute('aria-invalid', 'false');
errorEl.textContent = '';
}
}
3. Race conditions in async validation with AbortController
Permalink to "3. Race conditions in async validation with AbortController" When validation requires a network request (unique username, server-side constraint check), rapid typing can produce stale results from earlier requests overwriting correct results from later ones. Use AbortController to cancel in-flight requests when a new value is submitted:
let controller = null;
async function validateUnique(input, errorEl) {
if (controller) controller.abort(); // cancel previous request
controller = new AbortController();
// aria-busy="true": signals AT that a result is pending — SC 4.1.3
input.setAttribute('aria-busy', 'true');
try {
const res = await fetch(`/api/check?value=${encodeURIComponent(input.value)}`, {
signal: controller.signal
});
const { taken } = await res.json();
applyValidationResult(input, errorEl, taken ? 'Username is already taken.' : null);
} catch (err) {
if (err.name !== 'AbortError') throw err;
// AbortError means a newer request is in flight — do nothing
} finally {
input.removeAttribute('aria-busy');
}
}
FAQ
Permalink to "FAQ"Should I use aria-errormessage or aria-describedby for cell-level error messages?
Use both. aria-errormessage is the ARIA 1.1 standard for pointing to error text, but support remains inconsistent across AT versions — NVDA and VoiceOver may not announce it reliably without role="alert" on the target element. aria-describedby pointing to the same element works as a reliable fallback: the error text is read when the input receives focus in every major screen reader. Pair both attributes on the same input until AT support for aria-errormessage matures across the versions your users run.
When should I use role="alert" versus a polite aria-live region for cell errors?
Use role="alert" (which implies aria-live="assertive") only for hard validation failures that block a commit action — format errors, required field violations, or server-side uniqueness conflicts. Use a polite live region for transient hints shown during typing, such as character-count updates or password-strength indicators. Assertive announcements interrupt the screen reader’s current speech queue; triggering them on every keystroke creates announcement spam that disorients AT users navigating a large grid.
How do I prevent validation state from disappearing in a virtualized grid?
Store validation state in a Map keyed by row and column identifiers — not DOM node references. Virtualized grids recycle DOM nodes during scroll, so any ARIA state written to a node is discarded when that node leaves the viewport. During each render cycle, read from the Map to rehydrate aria-invalid and the error message text. Use IntersectionObserver to pause async validation for cells outside the viewport so you do not accumulate stale pending requests.
Related
Permalink to "Related"- Inline Editing & Form Controls — parent pattern covering edit-mode activation, focus return, and grid keyboard contracts
- Choosing Between Polite and Assertive aria-live Regions — when assertive announcement is warranted vs. disruptive
- aria-live Regions for Dynamic Data — broader live region architecture for data UIs
- Focus Management in Single-Page Apps — returning focus correctly after async save operations