Demonstration only. Use fictional details and a made-up password. Nothing is sent, logged or saved by this demo. Password inputs are masked in all six stages. The deliberately poor examples focus on labels, instructions and feedback.
Enable JavaScript to try the interactive stages. You can still read the related form testing method.
1. Start with the problems
What changed? This starting form uses placeholders, has no required indicators or password instructions, and reports only “Invalid input.”
Intentionally problematic: missing visible labels, required status and instructions; generic errors have no association or notification strategy.
2. Add visible labels
What changed? Visible label elements now use for and id to associate each label explicitly with its input.
Still incomplete: required status, password instructions, email type, autocomplete and understandable error handling are missing.
3. Identify required fields
What changed? Each label includes “(required)” and each control has the native required attribute.
Still incomplete: the password rule is hidden, and the email type and autocomplete are missing, and the custom error remains generic.
4. Connect password instructions
What changed? A visible password instruction says “Use at least 12 characters.” aria-describedby connects it to the password input.
Still incomplete: the email type and autocomplete are missing, and custom errors are not yet useful or associated.
5. Add email type and autocomplete
What changed? Email now uses type="email". Both fields gain relevant autocomplete attributes: email and new-password. Password has used type="password" in every stage. The submit button is native, as it has been throughout.
Still incomplete: native validation catches empty fields and email format, but a short password receives only a generic, disconnected custom error.
6. Help people correct errors
What changed? Custom submit-time validation checks required fields, email format and password length. novalidate disables browser popups so one consistent correction flow handles all errors.
Recommended pattern for this browser-only demo. Real account creation also needs server validation, security and network-error handling. This is not a production authentication system.
Working example
What happens on submit? No native required or email-format constraints are set. The JavaScript checks empty fields, email format and the 12-character password rule, but reports only “Invalid input.” for any failure. The missing rule is part of this intentionally poor starting example.
What happens on submit? There are still no native required or email-format constraints. Empty fields, malformed email and passwords shorter than 12 characters all reach the generic JavaScript error. Labels alone do not improve that feedback.
What happens on submit? The browser blocks submission when either required field is empty, before the submit handler runs. With both fields filled, malformed email or a password shorter than 12 characters reaches JavaScript and shows “Invalid input.” Email is still a text input; there is no minlength attribute.
What happens on submit? The browser still blocks empty required fields. With both fields filled, malformed email or a password shorter than the visible 12-character rule shows the generic JavaScript error. aria-describedby supplies instructions, not a validation constraint; there is no minlength attribute.
What happens on submit? The browser blocks empty required fields and malformed email before the submit handler runs. Once those checks pass, a password shorter than 12 characters shows the generic JavaScript error. There is no minlength attribute. To compare: submit a bad email first, then a valid email with a short password.
What happens on submit? novalidate disables native submission blocking, while required and type="email" still describe the controls. JavaScript handles empty fields, email format and the 12-character rule together, with associated errors and a focused summary. Correct the values and submit again to confirm success.
Why this change matters and how to test it
Why does it matter? Placeholder text disappears when you type. It is not a persistent visible label, even when a browser uses it as a fallback accessible name. A generic, disconnected error does not explain which value to correct.
Try it: Type in both fields and notice the prompts disappear. Submit an empty form. Can you tell what went wrong or how long the password needs to be?
Why does it matter? A label supplies the accessible name: what the control is for. It stays visible while typing and gives people a larger target to click. A placeholder should not replace it.
Try it: Click “Email address” and “Password” to focus their controls. Inspect their accessible names, then type and check that the labels remain visible.
Why does it matter? Visible text communicates the requirement to sighted users; required exposes it programmatically and enables native constraint validation. Neither presentation alone serves everyone.
Try it: Inspect the required state in the accessibility tree. Submit empty fields and try the browser’s native validation feedback. Its wording varies by browser and language.
Why does it matter? The label supplies the name; the description adds information about completing the field. Keep instructions visible so everyone can use them. Native HTML handles labels and required status; ARIA adds this descriptive relationship.
Try it: Focus Password with a screen reader and inspect its accessible description. Compare the name “Password (required)” with the separate length instruction.
Why does it matter? The email type supports an email-friendly mobile keyboard and native email format validation. Autocomplete identifies input purposes for browsers and password managers. These features do not replace understandable error handling.
Try it: Try a malformed email address and submit. Inspect the email type and both autocomplete attributes. On mobile, compare the email keyboard. Submit a short password to find the remaining error-handling problem.
Why does it matter? A focused error summary makes failure discoverable and links to each problem. Field-specific errors explain corrections, aria-invalid marks failed controls, and aria-describedby connects errors while retaining the password instruction. Values stay in place.
Try it: Submit empty fields, then an invalid email and a short password. Follow summary links using the keyboard. Correct the values and submit again to receive a focused confirmation.
HTML change to focus on
This is an excerpt highlighting this stage’s focus, not a complete form. Existing attributes are retained. Use the complete HTML and JavaScript examples below to run it.
<input id="forms-email" type="text" placeholder="Email address">
<input id="forms-password" type="password" placeholder="Password"><label for="forms-email">Email address</label>
<input id="forms-email" type="text">
<label for="forms-password">Password</label>
<input id="forms-password" type="password"><label for="forms-email">Email address (required)</label>
<input id="forms-email" type="text" required>
<label for="forms-password">Password (required)</label>
<input id="forms-password" type="password" required><label for="forms-password">Password (required)</label>
<p id="forms-password-hint">Use at least 12 characters.</p>
<input id="forms-password" type="password" required aria-describedby="forms-password-hint"><input id="forms-email" type="email" required autocomplete="email">
<input id="forms-password" type="password" required aria-describedby="forms-password-hint" autocomplete="new-password"><form id="forms-basics-form" novalidate>
<!-- Fields and controls omitted; see the complete example. -->
<p id="forms-email-error" class="field-error" hidden></p>
<p id="forms-password-error" class="field-error" hidden></p>
</form>Stage 1 shows the starting inputs. Stage 6 shows novalidate and the error containers; submit the form to inspect generated error associations in the current HTML below.
HTML for this stage
This is the complete form’s initial markup. Validation adds feedback and attributes after submission. Include the JavaScript below to run it locally; the HTML alone does not prevent navigation or implement the demonstration checks.
Current form HTML (entered values omitted)
This view follows the working form after submission, correction and reset. Stage 6 adds aria-invalid and error IDs to aria-describedby when fields fail. The password hint stays associated. Browser validation popups and browser-provided invalid states are not additional HTML attributes. Entered values are always omitted.
JavaScript for the working form
This is the actual shared form handler for all six stages, including the deliberately poor feedback in Stages 1–5. After adding the HTML, call connectForm(document.querySelector('#forms-basics-form'), STAGE_NUMBER), replacing STAGE_NUMBER with the selected number. Run this code in a module script. Stage 6 uses one custom validation flow; earlier stages also use browser constraint validation.
Errors are checked on submission, rather than announced while typing. Submit again after making corrections. On failure, Stage 6 focuses a summary with field links; on success it focuses a confirmation. There is no duplicate live-region announcement.
// Run after inserting the selected stage's HTML. All values remain in the DOM.
// No request, storage, logging, or real account creation takes place.
export function connectForm(form, stage) {
const email = form.querySelector('#forms-email');
const password = form.querySelector('#forms-password');
const feedback = form.querySelector('#forms-feedback');
const fields = [email, password];
function clearFeedback() {
feedback.hidden = true;
feedback.replaceChildren();
fields.forEach((field) => {
field.removeAttribute('aria-invalid');
if (field === password && stage >= 4) {
field.setAttribute('aria-describedby', 'forms-password-hint');
} else {
field.removeAttribute('aria-describedby');
}
const error = form.querySelector(`#${field.id}-error`);
if (error) {
error.hidden = true;
error.textContent = '';
}
});
}
form.addEventListener('reset', clearFeedback);
// Keep Stage 6 errors until the next submit; hide an outdated success message.
form.addEventListener('input', () => {
if (!form.querySelector('[aria-invalid]')) clearFeedback();
});
form.addEventListener('submit', (event) => {
event.preventDefault();
clearFeedback();
// In Stages 3–4 the browser blocks empty required fields before this event.
// Stage 5 also blocks malformed email. There is no native minlength rule.
// Other failures still reach the intentionally generic feedback below.
// Stage 6 uses novalidate so this handler manages every submission error.
const errors = [];
if (!email.value.trim()) {
errors.push([email, 'Enter your email address.']);
} else {
// Use the browser's email syntax check even in the early text-input stages.
const emailCheck = document.createElement('input');
emailCheck.type = 'email';
emailCheck.value = email.value;
if (!emailCheck.validity.valid) {
errors.push([email, 'Enter an email address such as learner@example.com.']);
}
}
if (!password.value) {
errors.push([password, 'Enter a password with at least 12 characters.']);
} else if (password.value.length < 12) {
errors.push([password, 'Use at least 12 characters for your password.']);
}
feedback.hidden = false;
if (errors.length && stage < 6) {
// Intentionally poor error handling for comparison. No focus or association.
feedback.textContent = 'Invalid input.';
return;
}
if (errors.length) {
const heading = document.createElement('h4');
heading.textContent = `Account not created: correct ${errors.length} ${errors.length === 1 ? 'error' : 'errors'}.`;
const list = document.createElement('ul');
errors.forEach(([field, message]) => {
const error = form.querySelector(`#${field.id}-error`);
error.textContent = message;
error.hidden = false;
field.setAttribute('aria-invalid', 'true');
const description = field === password ? 'forms-password-hint ' : '';
field.setAttribute('aria-describedby', description + error.id);
const link = document.createElement('a');
link.href = `#${field.id}`;
link.textContent = message;
link.addEventListener('click', (event) => {
event.preventDefault();
field.focus();
});
const item = document.createElement('li');
item.append(link);
list.append(item);
});
feedback.append(heading, list);
} else {
feedback.textContent = 'Demo complete. Your entries passed the checks. No account was created and nothing was sent.';
}
// One notification strategy: focus the summary or confirmation, no live region.
feedback.focus();
});
}
More ways to explore this demo
- Use Tab and Shift+Tab to move between controls, and Enter to activate stage links or submit. Sidebar links keep focus in the stage list; Previous and Next links take focus to the new stage heading. Both preserve your entries. Reset form clears the entries and feedback in the current stage.
- Click visible labels. Inspect the browser’s accessibility tree for names, required states, descriptions and invalid states.
- With a screen reader, compare what you hear when entering each field and after submitting. Announcements vary with the screen reader and browser combination.
- Try empty fields, a malformed email address and a password shorter than 12 characters. Then correct the values and submit again.
Stages 1–5 are educational comparisons, not recommended complete implementations. The omissions interact; they do not each automatically represent a separate WCAG failure. Automated checks can miss disappearing prompts, missing instructions and unclear recovery. They cannot prove full WCAG conformance.
Based on W3C WAI guidance on labels, instructions, validation and notifications, and ARIA21: identifying invalid fields. Use the Forms Tutorial alongside WCAG 2.2 when reviewing a complete implementation.