Define what the form must do before asking AI to edit it
This guide is for developers who can edit form markup and JavaScript and run Node.js commands. If you use a form plugin or a visual builder, first check its label, required-field, validation-message, and confirmation settings. Reproduce the problem on the published test page. If the tool cannot express the needed label or error relationship, give its developer the affected field, steps to reproduce, and expected behavior; a CSS change alone cannot repair missing HTML relationships.
Write down the fields, which ones are required, the accepted input formats, and what happens after submission. An assistant can change these rules while making the interface look better. Compare behavior as well as markup: field names, submitted values, endpoint, and optional fields should survive an accessibility patch unless you have approved a product change.
The first example requires full name, email, and password. A name consisting only of spaces is empty; single-word names and non-Latin characters are allowed. Email uses the browser's type="email" syntax check. The demo requires a nonempty password and leaves its value intact. A valid submission produces a local completion message and sends no data. Use your service's actual password requirements when adapting it.
Give the assistant the HTML, relevant CSS, validation handler, and these rules. In an application, include the component that renders errors and the server's error-response format. Replace real customer values and credentials with invented inputs. A screenshot helps explain appearance, but it cannot reveal missing label relationships or submission behavior.
Repair this form without changing its field names or business rules.
Use persistent associated labels and native controls.
Keep required instructions visible and keyboard focus visible.
Handle each required field and simultaneous errors.
Preserve existing help text when associating an error.
Clear corrected errors without moving focus during typing.
Show completion only after the defined submission succeeds.
Return the smallest patch and a test case for each behavior.
Inspect the patch before accepting it. Reject unexplained new required fields, restrictive name patterns, renamed payload keys, removed server checks, and layout rewrites that obscure the original defect. Ask for a second narrow patch when a test fails, supplying the input, actual behavior, and expected result.
Apply the AI patch to your own form
Make a copy of the affected page in your development or staging environment. Record one failing case, such as submitting an empty email and receiving an unexplained focus change. Save the current code so you can compare the patch or restore it.
Provide the assistant with the component or template, the CSS affecting controls, the validation and submission handlers, and a sample server error response with invented values. Include the surrounding dialog or step container when it affects focus. For a plugin, identify the template or extension point you are allowed to change.
Ask for a diff limited to that form. State the field names, endpoint, required rules, and expected focus destination. Request a separate explanation for any change to submitted values. Apply the diff in your test copy, then inspect the rendered labels, IDs, and error elements in the browser.
Repeat the original failing case. Then try every required field alone, all fields empty, correction and resubmission, and a confirmed success. Use the page checker below for matching native-input cases and the manual worksheet for other controls. Keep a failing check when asking AI for another patch; include its actual and expected results.
For a React or similar component, update errors and focus through the component's state and rendering lifecycle. A helper that adds an attribute directly to an input can lose that change on the next render. After a failed submit, wait until the error elements exist before moving focus. Test another render during correction so you notice a replaced input or a disappearing message.
Repair labels and keyboard operation with native HTML
The original example uses placeholders for identification, removes focus outlines, and makes its submit action a clickable div. Its scan reports contrast and document-language findings. The keyboard and validation problems still need interaction checks.
Use a visible label whose for matches the input's unique id. A placeholder can show an example, but it disappears during entry and should not carry the field's only instruction. The W3C labeling guidance explains the relationship and the larger clickable area a label provides. Check the rendered DOM when a framework generates IDs; correct source code does not help if two mounted forms share the same IDs.
<label for="email">Work email (required)</label>
<input id="email" name="email" type="email"
autocomplete="email" required>
<p id="email-error" class="error" hidden></p>
<button type="submit">Send request</button>
A native submit button already supports keyboard activation. Give password visibility, add-row, and similar actions type="button" so they do not submit accidentally. Keep DOM order aligned with reading order instead of repairing it with positive tabindex values. Test Tab, Shift+Tab, Enter, and Space on the controls where each key applies.
The repaired example uses a three-pixel focus outline with an offset. Tab through the form against the actual surrounding colors and check that containers or sticky headers do not clip or cover it. Scroll to a field through an error link and inspect the outline again.
Fix the contrast finding and check each visual state
Inspect the element named in the scan and read its computed foreground and background colors. Include a parent's background when the element itself is transparent. In the original example, the action uses #aaa text on #eee, a calculated ratio of about 2.00:1. The repaired button uses white text on #1b5e20, about 7.87:1.
The W3C text-contrast requirement is at least 4.5:1 for ordinary text and 3:1 for qualifying large text, with stated exceptions. Check small labels, hints, error messages, and button text against the ordinary-text threshold. Use the actual CSS color values; a screenshot alone is an unreliable way to calculate the ratio.
button {
color: #fff;
background: #1b5e20;
border: 2px solid #1b5e20;
}
button:focus {
outline: 3px solid #005fcc;
outline-offset: 3px;
}
Repeat the check in hover, focus, invalid, and submitting states because their styles may differ. Text contrast, control boundaries, and focus visibility are separate checks. For control borders and indicators, consult the non-text contrast guidance. Keep an error message in text as well as a colored border, and test focus in forced colors.
Choose browser validation or a complete custom error flow
Native validation is a useful starting point for a small form. Keep required and suitable input types, omit novalidate, and let the browser prevent an invalid submission. Check the resulting messages and focus behavior in the browsers your users use. The W3C validation tutorial describes built-in validation and why server validation is still necessary.
Custom validation is useful when your design needs an error summary, persistent field messages, or consistent handling of server errors. Adding novalidate suppresses the browser's normal submit-time validation UI. Your code then needs to identify every failed field, explain the correction, associate the message, and provide a usable route back to the field. Merely calling checkValidity() and moving focus leaves that work unfinished.
The partial repair demonstrates this failure. It adds labels and a real button, and axe reports no violations on its initial state. However, it only creates an email error. Empty name or password values prevent submission and receive focus without a field-specific explanation. Keep this intermediate version for comparison; use the complete local example for the remaining steps.
Separate missing input from malformed input. An empty email needs an instruction to enter it; an invalid email needs a format example. Do not add a complex email regex or corporate-domain restriction unless the application requires one. A syntax check does not confirm that an address exists. Validate the application's actual rules again on the server.
Connect every error while keeping useful field instructions
Give each field a persistent error container. On failure, put a specific correction in that container, reveal it, set aria-invalid="true", and add the error ID to aria-describedby. Use text and visible presentation together so the problem is understandable without identifying a red border.
The password already has a help paragraph. When it fails, its description becomes password-hint password-error. After correction, it returns to password-hint. Overwriting the entire relationship with the error ID would remove the useful instruction; leaving the old error attached after correction would retain stale information.
const descriptions = field.id === 'password'
? ['password-hint'] : [];
error.textContent = message;
error.hidden = !message;
if (message) {
field.setAttribute('aria-invalid', 'true');
descriptions.push(error.id);
} else {
field.removeAttribute('aria-invalid');
}
if (descriptions.length) {
field.setAttribute('aria-describedby', descriptions.join(' '));
} else {
field.removeAttribute('aria-describedby');
}
For a real component, preserve its actual hint IDs rather than copying the example's password-specific condition. Make the update function accept a field, its error element, and its normal description IDs. If the component library already manages those relationships, repair that mechanism instead of adding competing attributes outside it.
Choose validation timing deliberately. The example initially waits for submission. After that attempt, editing rechecks the affected field and clears corrected errors without shifting focus. It does not make each field message an assertive live region. An email address is usually incomplete while it is being typed; repeatedly interrupting the user with that fact can make correction harder.
For an availability check, consider feedback after a pause or on leaving the field. The W3C notification examples explain different timings. The worked asynchronous example below also prevents an older response from overwriting a newer value.
Handle simultaneous errors and keep focus predictable
Submitting three empty fields in the complete example produces three messages and a linked summary. With one invalid field, the example focuses that input. With several, it focuses the summary, which has tabindex="-1" so it can receive programmatic focus without adding a permanent Tab stop. Each summary link focuses its corresponding field.
A short form can instead always focus the first invalid field; a longer form may benefit from the summary first. Choose the destination before implementing it, include all identified errors, and make their fields reachable. An email-only error does not explain why focus moved to an empty name field.
The example moves focus to the summary. Check that your target screen reader announces the summary and lets users reach each error link. Adding overlapping alerts can cause duplicate or interrupted messages; listen to the complete failed-submit sequence when choosing the announcement strategy. During correction, update the summary without moving focus away from the input.
Keep entered values when submission fails. A reader should be able to correct one field and retry without reentering unrelated information. For passwords, preserve values in the current client-side form only as your application permits; do not echo a submitted password into a server-rendered page or log it in an error report.
Handle server errors and confirmed success
The three-field example finishes locally. To practice response handling, run npm run demo from the extracted kit and open http://127.0.0.1:4177/. The second form talks to a local teaching server that returns selectable outcomes and stores nothing. Use npm run check:flows to exercise its branches automatically.
Enter an invented name and email, choose Individual, and select Email field rejection from the Demo response menu. Submission returns HTTP 422 with this example response:
{
"fieldErrors": {
"email": "This demo address is already in use. Enter a different address."
}
}
The form maps email to its existing error container, keeps the email hint, lists the error, and focuses the email input. Change the address, select Success, and retry. The stale error clears and the new response confirms the next step. Your API may use a different status or structure; write that mapping explicitly instead of expecting every server to return this JSON.
for (const [key, message] of Object.entries(result.fieldErrors)) {
if (Object.hasOwn(controls, key) &&
typeof message === 'string' && message.trim()) {
setError(key, message);
} else {
formError.textContent = 'Some information could not be accepted.';
}
}
focusErrors();
The full example in flows.html also handles a missing error map and a rejected field that is currently disabled. Insert server messages as text and allow only known field keys. An unknown key needs a visible form-level fallback; silently discarding it can leave the user with a failed submission and no explanation.
Start the submitting state after client validation passes. In the example, an in-flight guard blocks a second request, the editable fields are temporarily disabled, and the submit button exposes aria-disabled="true" while keeping its place in the keyboard order. The handler enforces that state; the ARIA attribute alone does not stop clicks. A finally block restores the controls before errors receive focus.
| Response or condition | Form behavior to implement and verify |
|---|---|
| Server rejects a particular field | Map the known field key to its error element, retain entered values, rebuild the summary, and use the chosen focus destination. |
| Server returns an overall rejection | Show a useful form-level explanation and a recovery action. Do not invent a field error when the server did not identify one. |
| Request fails or times out | Restore the submit control, retain useful input, and explain how to retry. Avoid reporting that nothing happened if the request's outcome is unknown. |
| Request succeeds | Display the confirmed next step through an appropriate status or destination page. Clear fields only when that behavior makes sense for the task. |
Try Service unavailable, Dropped response, Request timeout, and 200 without confirmed success in the same local form. Each produces a form-level explanation, keeps the values, and restores the submit action. The timeout example aborts after one second to make the branch quick to try; choose a suitable limit for your service. Aborting the browser request does not establish whether a real server already processed it. Define a way to check the outcome before retrying, and coordinate retry and idempotency behavior with the backend for operations that can create duplicates.
Check both the HTTP status and the response body. Fetch does not reject solely because an HTTP error status was returned. A 200 response also needs to satisfy your application's success rule. Test malformed JSON and unexpected success-shaped responses so the interface does not announce completion prematurely.
Prevent stale asynchronous feedback
In the second example, enter taken@example.com and leave the email field. Before its response arrives, enter new@example.com and leave the field again. The server deliberately returns the first response later. The current address must retain its own availability message.
const version = ++availabilityVersion;
const value = email.value;
const response = await fetch('/demo/availability?email=' +
encodeURIComponent(value));
const result = await response.json();
if (version !== availabilityVersion || email.value !== value) return;
availability.textContent = result.available
? 'Demo address available.'
: 'Demo address already in use. Choose another address.';
Increment the version and clear stale feedback whenever the input changes, including while a request is pending. Checking only the value is insufficient when the user changes it and later restores the same text. The full handler also rejects failed responses, handles JSON errors, and supplies a useful fallback when availability cannot be checked.
Availability feedback is advisory. Submit-time server validation still decides whether to accept the address. Use a polite status for this feedback and test its timing with a screen reader; frequent announcements can interfere with typing. Apply the same response-order check to username lookups or address suggestions.
Leave enough space for the feedback near the field. In this example, inserting a checking message on blur initially pushed the submit button away during a click. The corrected layout reserves room for that message. Try leaving the field by clicking Submit as well as by pressing Tab, especially on a narrow screen.
Run the independent checks and read their results
Download and extract the form repair test kit. Use Node.js 20 or later. The kit includes before, partial, and complete examples, the server-response form, browser checkers, locked dependencies, saved results, and a manual worksheet. From the extracted directory, run:
npm ci
npx playwright install chromium
npm run check
npm run check checks the supplied three-field examples; it does not visit your website. It writes results and screenshots to run-output/ and exits unsuccessfully when an expected behavior fails. Run npm run check:flows separately for the local server, asynchronous feedback, grouped inputs, and conditional fields. Use the configurable page checker in the next section for your own form.
The original example has two axe violations. The partial repair has zero initial violations despite its missing name error. The complete example passes 25 checks and has zero axe violations in the initial, all-empty, and completion states. The response example and page adapter pass 20 further checks, with zero axe violations in the response form's initial, field-error, and overall-error states. The result record identifies each check, code hash, and exact environment.
One mutation removes the name label, which axe detects. Another removes the name field's error-description relationship after invalid submission. The behavioral check rejects that copy while its axe scan still reports zero violations. This demonstrates why scanning and interaction assertions are both needed. Keep mutation failures as evidence that the checks can detect the defects they are supposed to prevent.
When reviewing a scan, inspect affected elements and explanations rather than only counting violations. Also review incomplete results that require judgment. Run scans after errors, expanded controls, or confirmation states become visible. The Playwright accessibility-testing documentation explains testing page states and the limitations of automated coverage.
Configure checks for your own test page
The kit's check-page.cjs accepts a page URL and a JSON configuration. Its default run opens the page, checks the configured labels and unique IDs, and scans the selected form without submitting. First try it against the complete example served by npm run demo:
node check-page.cjs http://127.0.0.1:4177/after.html page-check.example.json
Copy page-check.example.json to my-form.json. Replace formSelector and submitSelector with selectors for your rendered form and submit button. For every native input you want checked, set its control selector, exact visible label, and error selector. Give the error container an ID that your code adds to the input's description when invalid.
{
"key": "email",
"selector": "#contact-email",
"label": "Email (required)",
"errorSelector": "#contact-email-error"
}
Update the cases with your required fields and invented inputs. invalid lists the field keys expected to fail; focusSelector identifies the destination after submission. If your form always focuses the first invalid field, change the all-empty expectation to that field. Preserve the assertion that every failed field has a visible message and the correct description relationship.
node check-page.cjs https://staging.example.com/request my-form.json
node check-page.cjs https://staging.example.com/request my-form.json --exercise
--exercise fills and submits each configured case. Use it on a test page with controlled outcomes; even a supposedly invalid request may reach the server. The sample cases cover empty and malformed input, not successful account creation. Review the output's violations, incomplete findings, and focus failures, then add cases for your own rules.
This adapter covers native inputs and the configured error states. Add application-specific assertions for choice groups, steps, server responses, or custom controls. For pages that require sign-in, run equivalent checks inside your application's existing authenticated test setup. A passing example configuration does not cover controls or states you left out.
Exercise the error and recovery paths with a keyboard
Use invented values and run these cases on your actual form. The checker covers the local example's corresponding paths. Additional business rules need their own inputs and expected outcomes.
| Case | Expected result |
|---|---|
| Only name is empty | Name has a visible associated error; focus reaches it. |
| Name contains only spaces | The example treats it as missing without imposing a Latin-character or two-word-name rule. |
| Only email is empty | The message asks for an email, rather than giving a format complaint. |
| Email has invalid syntax | The message supplies a usable format example. |
| Only password is empty | Password has its own error while its normal hint remains associated. |
| All fields are empty | All errors are listed and the summary receives focus in the example. |
| Follow a summary link | The corresponding control receives focus and is visible. |
| Correct the fields | Stale messages and invalid states clear; editing keeps focus in the field. |
| Submit valid values | Completion reflects the defined local or server outcome. |
| Activate password visibility | The button works by keyboard, keeps its focus, and does not submit. |
Repeat the sequence using Tab and Shift+Tab rather than clicking into each input. Check that every interactive control is reachable, the order makes sense, and validation does not create a keyboard trap. Inspect whether an error summary link still works after a framework rerenders the form. A component remount that replaces the focused input can interrupt correction even when the replacement has valid markup.
Check screen-reader output separately from DOM assertions
Use the manual checklist to record the browser, screen reader, versions, input case, observed output, and unresolved issue. Run a combination your users rely on, such as VoiceOver with Safari or NVDA with a supported desktop browser.
Navigate to each field and listen for its name, control type, required status, and relevant help. Submit one missing field and verify that the error can be discovered with the focused control. Then submit several errors, follow a summary link, correct the input, and confirm that stale errors are no longer presented. Verify the completion message or destination after a successful attempt.
Record what you actually hear. If a message is silent, first check the rendered relationship, whether the message exists when the field receives focus, and whether a hidden ancestor removes it from the accessibility tree. If it repeats, inspect overlapping live regions and focus changes. Adjust the announcement strategy and repeat the same case rather than adding more ARIA attributes until the DOM looks plausible.
A smoke check records output and navigation. Testing with disabled users can also reveal whether the explanation and recovery flow are understandable during the actual task. Keep an observation such as “the summary heading was spoken, but the error links were hard to discover” with the exact steps that produced it.
Check zoom, narrow layouts, and forced colors
The three-field checker checks a 320 CSS-pixel viewport, doubled root text size, and a focus outline under forced-color emulation. Follow those checks with native browser zoom and your target operating system's settings on the integrated page.
On the actual page, increase text size and test browser zoom, including the narrow effective viewport at high zoom. Check labels, hints, errors, buttons, and the summary together. Long messages should wrap without covering controls, and reaching a field should not require horizontal scrolling through the form. The W3C reflow explanation distinguishes the relevant viewport conditions and exceptions.
Repeat the invalid state with your site's sticky navigation, cookie banners, dialogs, and application shell present. A standalone example cannot reveal their obstruction. In forced colors, confirm that inputs and focus remain distinguishable when backgrounds or shadows change. Keep a real outline or border available instead of depending entirely on a decorative shadow.
Validate choice groups and conditional fields
The local response form adds Account type, with Individual and Team radio options. Their shared name="kind" makes them one choice group. A fieldset and legend provide the question while each option keeps its own label, following the W3C grouping guidance.
<fieldset>
<legend>Account type (required)</legend>
<label><input id="individual" type="radio" name="kind"
value="individual" required> Individual</label>
<label><input id="team" type="radio" name="kind"
value="team" required> Team</label>
<p id="kind-error" hidden></p>
</fieldset>
Fill Name and Email and submit without choosing a type. The group receives one correction, “Choose an account type,” and its summary link targets the first radio. The example associates that shared error with both options and clears it when a selection is made. Check that your screen reader also presents the legend and option names. Use Tab to reach the group and arrow keys to change its native selection.
Select Team. The company field becomes visible, enabled, and required. Submit with it empty: its own message appears. Fill it, then select Individual. The branch must disappear, its requirement and error must clear, and its value must be omitted from the payload. The example keeps the typed company value available if Team is selected again.
branch.hidden = !isTeam;
company.disabled = !isTeam;
company.required = isTeam;
if (!isTeam) setError('company', '');
updateSummary();
CSS hiding alone leaves a field's form behavior intact. A disabled control is excluded from submission and constraint validation. Apply that state only when the branch is inactive, and mirror the conditional rule on the server. The demo server independently rejects a Team request with no company even when the client handler is bypassed.
If a script hides a branch while its input has focus, move focus to the control that selects the surviving branch. When the user changes a radio normally, leave focus there. For repeated rows, use unique IDs and return focus to the add-row button or a nearby remaining row when removing the active one. Test these transitions after errors have appeared as well as on a clean form.
Carry errors through steps and custom controls
For a multi-step form, define which step owns each field and what Back preserves. Validate the visible step before Next. After rendering the next step, move focus to its heading or another documented starting point and expose the progress in text. The W3C multi-page guidance covers progress and navigation.
Consider a three-step request: contact details, options, and review. A final server response rejects the email on step one. Keep all entered values, map the email key to step one, render that step with the error already attached, then focus its summary or email field. Focusing a still-hidden email control from the review step leaves the user without a usable recovery path.
| Transition to test | Expected outcome |
|---|---|
| Next with a missing field | Stay on the current step, explain the field error, and focus the chosen error destination. |
| Back after entering later-step values | Earlier and later values remain available; the visible step and progress agree. |
| Final rejection of an earlier field | Return to the owning step with other values preserved and the server error discoverable. |
| Earlier answer removes a later branch | Clear errors and requirements for the inactive branch and exclude it from the final payload. |
For custom select menus, date pickers, and comboboxes, first consider whether a native control meets the requirement. A custom component needs the correct name, role, states, keyboard behavior, and focus management across opening, selection, dismissal, and errors. Adding role="button" to a clickable container does not implement those behaviors. Follow the relevant custom-control guidance and component pattern, then test the expanded state.
For an account form, preserve password-manager support and allow paste. The visibility control should state the action it will perform and must not submit the form. If authentication adds memorization tasks, puzzles, or verification steps, review those steps too; the accessible-authentication guidance explains why assistance mechanisms matter.
Check the integrated form before release
Keep the final code, scan output, failed and corrected cases, and manual observations together. Confirm that the real endpoint still receives the expected field names and active-branch values. Repeat the cases with your site's dialogs, navigation, and confirmation page present, then verify the correction path after a server rejection.
The downloads include local browser tests and a synthetic HTTP response server. Exact versions and results are recorded with the code. Screen-reader speech, native 400-percent zoom, Safari/Firefox, real account services, authentication security, multi-step implementations, and custom widgets remain outside those recorded tests. Use the manual worksheet and your application's own checks for those areas; a passing scan does not establish WCAG conformance.
