- name
- wcag-form-accessibility-nodejs
- description
- Implements WCAG 2.2 AA form accessibility patterns for Node.js/JavaScript (server-side rendering, React/Next.js), including label-to-input association, error identification, focus management, dynamic updates with aria-live, and form state management with aria-invalid/aria-required.
- license
- MIT
- compatibility
- opencode
- metadata
- {"version":"1.0.0","domain":"coding","role":"implementation","scope":"implementation","output-format":"code","triggers":"wcag form accessibility, form validation a11y, aria-invalid, aria-describedby, screen reader forms, keyboard form navigation, next.js server actions forms, how do i make accessible forms","related-skills":[],"archetypes":"tactical","anti_triggers":"brainstorming, vague ideation","response_profile":{"verbosity":"low","directive_strength":"high","abstraction_level":"operational"}}
# WCAG Form Accessibility for Node.js/JavaScript
Implements WCAG 2.2 AA compliant form rendering and validation patterns for server-side and client-side JavaScript. When loaded, this skill enables building forms that are fully operable via keyboard, properly announced to screen readers, and resilient to validation errors. Covers label-to-input association (1.3.1), error messaging (3.3.1), focus management (2.4.3), and dynamic content updates (4.1.3).
## TL;DR Checklist
- [ ] Every `<input>`, `<select>`, and `<textarea>` has an associated `<label>` with `for="input-id"` attribute matching the input's `id`
- [ ] Form submission errors are rendered in a container with `role="alert"` or `aria-live="assertive"` to announce immediately to screen readers
- [ ] Each input with validation errors has `aria-invalid="true"` and `aria-describedby="error-id"` linking to the error message element
- [ ] Focus is programmatically moved to the first invalid field on form submission failure using `element.focus()`
- [ ] Dynamic validation feedback uses `aria-live="polite"` for non-critical messages and `aria-live="assertive"` for errors
- [ ] All interactive form controls are reachable via Tab key in logical order (Tab/Shift+Tab)
- [ ] Form controls have visible focus indicators (minimum 2px solid ring with 3:1 contrast)
- [ ] Server-rendered HTML includes all accessible attributes before JavaScript loads; progressive enhancement is verified
- [ ] Tested with screen reader (VoiceOver, NVDA) to confirm labels, errors, and state are announced correctly
---
## When to Use
Use this skill when:
- Building server-side rendered forms (Express, Fastify, Next.js server components) that must be accessible without client-side JavaScript
- Creating React/Next.js form components with real-time validation feedback
- Implementing multi-step forms or forms with conditional fields that require dynamic ARIA updates
- Handling form submission errors and need to communicate failures to screen reader users
- Designing custom form controls (autocomplete, date picker, combobox) with keyboard support
- Validating WCAG 2.2 AA compliance for forms in security-sensitive contexts (auth, payments, PII collection)
- Migrating legacy forms to meet accessibility requirements or passing accessibility audits
---
## When NOT to Use
Avoid this skill for:
- Purely presentational non-interactive layouts (use layout patterns instead)
- Simple client-side form libraries already providing built-in a11y support (Formik, React Hook Form with auto-labeling)
- Rapid prototyping where accessibility is deferred to a later phase (accessibility must be built in from the start, not retrofitted)
- Forms where user research has determined your audience does not include keyboard or screen reader users (rare; assume inclusive by default)
---
## Core Workflow
1. **Establish Form Structure and Input Inventory** — List all form inputs, their validation rules, error messages, and conditional visibility. Create an accessibility checklist: Does each input have a unique `id` and associated `<label>`? Are error messages descriptive and linked via `aria-describedby`? **Checkpoint:** Run the form through `axe DevTools` or Lighthouse a11y audit; expect zero "critical" or "serious" violations.
2. **Implement Server-Side Rendering with Semantic HTML** — Use native `<form>`, `<input>`, `<label>`, and `<select>` elements. Generate stable, unique `id` attributes for each input (use a counter, UUID, or hash if dynamic). Render labels with `for="input-id"` matching the input's `id`. Include `aria-required="true"` on required fields and `aria-invalid="true"` on fields with errors. **Checkpoint:** Disable JavaScript and verify the form renders with all labels visible and inputs focusable via Tab.
3. **Render Error Messages with Proper ARIA Linkage** — Create an error container with `id="errors"` and `role="alert"` at the top of the form. For each field error, render a message in a `<div id="field-error-name">` below or next to the input. Link the input to its error with `aria-describedby="field-error-name"`. Use `aria-invalid="true"` on the input to signal validation failure. **Checkpoint:** Open the page in a screen reader (VoiceOver on macOS or NVDA on Windows); navigate to an invalid input and confirm the error message is announced.
4. **Implement Focus Management on Validation Failure** — On form submission, validate all fields server-side and re-render the page with error markup. Use JavaScript (if available) to programmatically focus the first invalid input: `document.querySelector('[aria-invalid="true"]')?.focus()`. For SPA forms (React, Vue), move focus after validation state updates. **Checkpoint:** Submit a form with errors and verify that focus jumps to the first invalid field; screen readers announce both the field label and the error.
5. **Add Dynamic Validation Feedback with aria-live** — For real-time validation (as the user types), wrap feedback messages in a `<div aria-live="polite" aria-atomic="true">` for non-critical messages (e.g., "Password strength: medium") or `aria-live="assertive"` for errors that require immediate attention (e.g., "Email already in use"). Update the div's text content to trigger announcement. **Checkpoint:** Type into a field with real-time validation and confirm the feedback is announced without interrupting typing.
6. **Test with Keyboard Navigation and Screen Readers** — Walk through the form using only Tab, Shift+Tab, Enter, and Arrow keys (no mouse). Verify Tab order follows the visual flow and focus indicators are always visible. Open the form in a screen reader and navigate through each field, confirming labels, required status, error messages, and form purpose are announced clearly. **Checkpoint:** Complete the entire form using only keyboard + screen reader; no mouse required.
---
## Implementation Patterns
### Pattern 1: Server-Rendered Form with WCAG-Compliant Label and Error Linking
Demonstrates a complete server-side form component with label association, error messages linked via `aria-describedby`, and `aria-invalid` state. This pattern works without JavaScript.
```javascript
/**
* Pattern 1: Server-rendered form with WCAG-compliant labels and error linking.
*
* This pattern shows how to render a form server-side that is fully accessible
* to keyboard and screen reader users, even before JavaScript loads.
*
* Key WCAG criteria addressed:
* - 1.3.1 Info and Relationships: Label-to-input association via for/id
* - 3.3.1 Error Identification: Error messages linked to inputs via aria-describedby
* - 4.1.2 Name, Role, Value: aria-required and aria-invalid on form controls
*/
// Express server example
app.get('/register', (req, res) => {
// In a real app, this comes from form submission with validation errors
const errors = {};
const formData = {};
// Generate stable IDs for each form field (can use uuid or hash)
const fieldIds = {
email: 'field-email',
password: 'field-password',
confirmPassword: 'field-confirm-password',
agreeToTerms: 'field-agree-to-terms',
};
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register Account</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 2rem auto; }
.form-group { margin-bottom: 1.5rem; }
label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
input, textarea, select { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
input:focus, textarea:focus, select:focus { outline: 2px solid #0066cc; outline-offset: 2px; }
input[aria-invalid="true"] { border-color: #dc2626; }
.error-message { color: #dc2626; font-size: 0.875rem; margin-top: 0.25rem; }
[role="alert"] { background: #fee; padding: 1rem; border-radius: 4px; margin-bottom: 1rem; border-left: 4px solid #dc2626; }
</style>
</head>
<body>
<h1>Create Account</h1>
<!-- Error summary (WCAG 3.3.1) -->
${Object.keys(errors).length > 0 ? `
<div role="alert" aria-live="assertive">
<strong>Please fix the following errors:</strong>
<ul>
${Object.entries(errors)
.map(([field, msg]) => `<li><a href="#${fieldIds[field]}">${msg}</a></li>`)
.join('')}
</ul>
</div>
` : ''}
<form method="POST" action="/register" novalidate>
<!-- Email field -->
<div class="form-group">
<label for="${fieldIds.email}">Email Address <span aria-label="required">*</span></label>
<input
type="email"
id="${fieldIds.email}"
name="email"
value="${formData.email || ''}"
required
aria-required="true"
aria-invalid="${errors.email ? 'true' : 'false'}"
${errors.email ? `aria-describedby="error-${fieldIds.email}"` : ''}
autocomplete="email"
>
${errors.email ? `
<div id="error-${fieldIds.email}" class="error-message" role="status">
${errors.email}
</div>
` : ''}
</div>
<!-- Password field -->
<div class="form-group">
<label for="${fieldIds.password}">Password <span aria-label="required">*</span></label>
<input
type="password"
id="${fieldIds.password}"
name="password"
required
aria-required="true"
aria-invalid="${errors.password ? 'true' : 'false'}"
${errors.password ? `aria-describedby="error-${fieldIds.password}"` : ''}
autocomplete="new-password"
>
${errors.password ? `
<div id="error-${fieldIds.password}" class="error-message" role="status">
${errors.password}
</div>
` : ''}
</div>
<!-- Confirm Password field -->
<div class="form-group">
<label for="${fieldIds.confirmPassword}">Confirm Password <span aria-label="required">*</span></label>
<input
type="password"
id="${fieldIds.confirmPassword}"
name="confirmPassword"
required
aria-required="true"
aria-invalid="${errors.confirmPassword ? 'true' : 'false'}"
${errors.confirmPassword ? `aria-describedby="error-${fieldIds.confirmPassword}"` : ''}
autocomplete="new-password"
>
${errors.confirmPassword ? `
<div id="error-${fieldIds.confirmPassword}" class="error-message" role="status">
${errors.confirmPassword}
</div>
` : ''}
</div>
<!-- Checkbox field (terms of service) -->
<div class="form-group">
<input
type="checkbox"
id="${fieldIds.agreeToTerms}"
name="agreeToTerms"
required
aria-required="true"
aria-invalid="${errors.agreeToTerms ? 'true' : 'false'}"
${errors.agreeToTerms ? `aria-describedby="error-${fieldIds.agreeToTerms}"` : ''}
>
<label for="${fieldIds.agreeToTerms}" style="display: inline; margin-left: 0.5rem;">
I agree to the <a href="/terms">Terms of Service</a>
</label>
${errors.agreeToTerms ? `
<div id="error-${fieldIds.agreeToTerms}" class="error-message">
${errors.agreeToTerms}
</div>
` : ''}
</div>
<button type="submit">Create Account</button>
</form>
<script>
// Progressive enhancement: Focus first invalid field if errors exist
const firstInvalid = document.querySelector('[aria-invalid="true"]');
if (firstInvalid) {
firstInvalid.focus();
}
</script>
</body>
</html>
`;
res.send(html);
});
// ✅ GOOD: All labels have for="id", errors link via aria-describedby, required/invalid states explicit
// Screen reader announces: "Email Address, required, edit text, invalid" + error message when focused
// Tab key navigates through all fields in order; focus visible on each
// ❌ BAD: No labels, no error linking, no required/invalid state
// <input type="email" name="email" placeholder="Email">
// <div style="color: red;">Email is required</div>
// Screen reader cannot associate label with input; error message is disconnected
```
---
### Pattern 2: React/Next.js Client-Side Form with useId, useActionState, and aria-live
Demonstrates a modern React form using the `useId` hook for stable HTML IDs, `useActionState` (Next.js Server Actions) for server-side validation, and `aria-live` regions for dynamic error announcements.
```typescript
/**
* Pattern 2: React/Next.js form with useId, useActionState, and aria-live.
*
* Uses:
* - useId() for stable HTML id generation (React 18+)
* - useActionState() for Next.js Server Actions integration
* - aria-live="assertive" for error region with role="alert"
* - aria-describedby linking inputs to error messages
* - aria-invalid for field validation state
*
* WCAG criteria: 1.3.1, 3.3.1, 4.1.2, 4.1.3
*/
import { useId, useState } from 'react';
import { useActionState } from 'react';
// Server action (runs on server, handles validation)
async function validateAndCreateAccount(
_prevState: unknown,
formData: FormData
) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
const agreeToTerms = formData.get('agreeToTerms') === 'on';
const errors: Record<string, string> = {};
// Validation logic runs server-side
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!email.includes('@')) {
errors.email = 'Enter a valid email address';
}
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 12) {
errors.password = 'Password must be at least 12 characters';
}
Auf GitHub ansehen