Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsBlogPro
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Authors
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges
  • Chrome Extension
  • Skill Manager

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

ProTermsPrivacyRefunds
Back to skills

Wcag Form Accessibility Nodejs

ASecurity

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.

6 stars
0 votes
0 copies
1 views
Added 9/25/2026
developmentjavascripttypescriptgojavareactvuenextjsnodenodejsexpress

Works with

cliapi

Security Analysis

A100/100

Scanned 9/25/2026

$npx -y skills add paulpas/agent-skill-router --skill wcag-form-accessibility-nodejs --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Wcag Form Accessibility Nodejs?

Add the live security badge to your README — it updates automatically with every re-scan.

Security grade badge for Wcag Form Accessibility Nodejs
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/paulpas-wcag-form-accessibility-nodejs/badge)](https://www.skillsdirectory.com/skills/paulpas-wcag-form-accessibility-nodejs)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
Files
SKILL.md
---
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';
  }

  if (password !== confirmPassword) {
    errors.confirmPassword = 'Passwords do not match';
  }

  if (!agreeToTerms) {
    errors.agreeToTerms = 'You must agree to the Terms of Service';
  }

  if (Object.keys(errors).length > 0) {
    return { success: false, errors };
  }

  // Account creation logic (omitted for brevity)
  return { success: true, message: 'Account created successfully' };
}

export function RegisterForm() {
  const [state, formAction, isPending] = useActionState(
    validateAndCreateAccount,
    { success: false, errors: {} }
  );

  const [focusFirstError, setFocusFirstError] = useState(false);

  // Generate stable IDs for each field (re-used across renders)
  const emailId = useId();
  const emailErrorId = useId();
  const passwordId = useId();
  const passwordErrorId = useId();
  const confirmPasswordId = useId();
  const confirmPasswordErrorId = useId();
  const agreeToTermsId = useId();
  const agreeToTermsErrorId = useId();
  const errorsRegionId = useId();

  const errors = state.errors || {};

  // Focus first invalid field on error (client-side for UX)
  React.useEffect(() => {
    if (!state.success && Object.keys(errors).length > 0) {
      const firstErrorField = document.querySelector(
        '[aria-invalid="true"]'
      ) as HTMLInputElement;
      if (firstErrorField) {
        firstErrorField.focus();
        setFocusFirstError(true);
      }
    }
  }, [errors, state.success]);

  return (
    <form action={formAction} className="register-form" noValidate>
      <h1>Create Account</h1>

      {/* Error summary region (WCAG 3.3.1) */}
      {Object.keys(errors).length > 0 && (
        <div
          id={errorsRegionId}
          role="alert"
          aria-live="assertive"
          aria-atomic="true"
          className="form-errors"
        >
          <strong>Please fix the following errors:</strong>
          <ul>
            {Object.entries(errors).map(([field, message]) => (
              <li key={field}>
                <a href={`#${getFieldId(field)}`}>{message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      {/* Email field */}
      <div className="form-group">
        <label htmlFor={emailId}>
          Email Address{' '}
          <span aria-label="required" className="required-indicator">
            *
          </span>
        </label>
        <input
          type="email"
          id={emailId}
          name="email"
          required
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? emailErrorId : undefined}
          autoComplete="email"
          className={errors.email ? 'field-error' : ''}
          disabled={isPending}
        />
        {errors.email && (
          <div id={emailErrorId} className="error-message" role="status">
            {errors.email}
          </div>
        )}
      </div>

      {/* Password field */}
      <div className="form-group">
        <label htmlFor={passwordId}>
          Password{' '}
          <span aria-label="required" className="required-indicator">
            *
          </span>
        </label>
        <input
          type="password"
          id={passwordId}
          name="password"
          required
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? passwordErrorId : undefined}
          autoComplete="new-password"
          className={errors.password ? 'field-error' : ''}
          disabled={isPending}
        />
        {errors.password && (
          <div id={passwordErrorId} className="error-message" role="status">
            {errors.password}
          </div>
        )}
      </div>

      {/* Confirm Password field */}
      <div className="form-group">
        <label htmlFor={confirmPasswordId}>
          Confirm Password{' '}
          <span aria-label="required" className="required-indicator">
            *
          </span>
        </label>
        <input
          type="password"
          id={confirmPasswordId}
          name="confirmPassword"
          required
          aria-required="true"
          aria-invalid={!!errors.confirmPassword}
          aria-describedby={
            errors.confirmPassword ? confirmPasswordErrorId : undefined
          }
          autoComplete="new-password"
          className={errors.confirmPassword ? 'field-error' : ''}
          disabled={isPending}
        />
        {errors.confirmPassword && (
          <div
            id={confirmPasswordErrorId}
            className="error-message"
            role="status"
          >
            {errors.confirmPassword}
          </div>
        )}
      </div>

      {/* Terms of Service checkbox */}
      <div className="form-group">
        <input
          type="checkbox"
          id={agreeToTermsId}
          name="agreeToTerms"
          required
          aria-required="true"
          aria-invalid={!!errors.agreeToTerms}
          aria-describedby={
            errors.agreeToTerms ? agreeToTermsErrorId : undefined
          }
          disabled={isPending}
        />
        <label htmlFor={agreeToTermsId} className="checkbox-label">
          I agree to the{' '}
          <a href="/terms" target="_blank" rel="noopener noreferrer">
            Terms of Service
          </a>
        </label>
        {errors.agreeToTerms && (
          <div
            id={agreeToTermsErrorId}
            className="error-message"
            role="status"
          >
            {errors.agreeToTerms}
          </div>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        aria-busy={isPending}
      >
        {isPending ? 'Creating Account...' : 'Create Account'}
      </button>
    </form>
  );
}

// Helper function to map field names to their IDs (memoize in real apps)
function getFieldId(fieldName: string): string {
  const map: Record<string, string> = {
    email: 'emailId',
    password: 'passwordId',
    confirmPassword: 'confirmPasswordId',
    agreeToTerms: 'agreeToTermsId',
  };
  return map[fieldName] || '';
}

// ✅ GOOD: useId generates stable IDs across re-renders; aria-describedby links errors
// useActionState integrates server validation; aria-live="assertive" announces errors
// aria-invalid and aria-required signal state to screen readers
// Screen reader announces: "Email Address, required, edit text, invalid, Email is required" when focused

// ❌ BAD: Generating random IDs on every render
// const emailErrorId = Math.random().toString();
// aria-describedby will reference stale IDs; form becomes inaccessible on re-render
```

---

### Pattern 3: Real-Time Password Strength Validation with aria-live

Demonstrates dynamic validation feedback announced to screen readers without interrupting user input. Uses `aria-live="polite"` for non-critical updates and `aria-atomic="true"` to announce the full message.

```typescript
/**
 * Pattern 3: Real-time validation with aria-live and aria-atomic.
 * 
 * Shows how to provide dynamic feedback (password strength, character count)
 * that is announced to screen readers but does not interrupt typing.
 * 
 * Key ARIA attributes:
 * - aria-live="polite": Announce after user finishes typing
 * - aria-atomic="true": Announce the entire message, not just changes
 * - aria-describedby: Link input to the live region
 * - role="status": Marks this as a status message (implicit aria-live="polite")
 */

import { useId, useState } from 'react';

export function PasswordField() {
  const [password, setPassword] = useState('');
  const [strength, setStrength] = useState<'weak' | 'fair' | 'good' | 'strong' | null>(null);

  const passwordId = useId();
  const strengthId = useId();

  const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setPassword(value);

    // Calculate strength (simple example)
    if (value.length < 8) {
      setStrength('weak');
    } else if (value.length < 12) {
      setStrength('fair');
    } else if (value.length < 16) {
      setStrength('good');
    } else {
      setStrength('strong');
    }
  };

  const strengthMessage = {
    weak: 'Password is too short. Use at least 12 characters.',
    fair: 'Password is fair. Add special characters for better security.',
    good: 'Password is good. Add uppercase and numbers for better security.',
    strong: 'Password is strong and meets all security requirements.',
  };

  return (
    <div className="form-group">
      <label htmlFor={passwordId}>
        Password{' '}
        <span aria-label="required" className="required-indicator">
          *
        </span>
      </label>

      <input
        type="password"
        id={passwordId}
        value={password}
        onChange={handlePasswordChange}
        required
        aria-required="true"
        aria-describedby={`${strengthId} password-requirements`}
        autoComplete="new-password"
        minLength={12}
      />

      {/* Strength feedback with aria-live (WCAG 4.1.3) */}
      <div
        id={strengthId}
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className={`strength-feedback strength-${strength}`}
      >
        {password && strength ? (
          <>
            <strong>Strength: {strength}</strong> — {strengthMessage[strength]}
          </>
        ) : null}
      </div>

      {/* Static password requirements (always visible) */}
      <div id="password-requirements" className="requirements">
        <p>Password must include:</p>
        <ul>
          <li aria-live="polite">
            {password.length >= 12 ? '✓' : '○'} At least 12 characters
          </li>
          <li aria-live="polite">
            {/[A-Z]/.test(password) ? '✓' : '○'} Uppercase letter
          </li>
          <li aria-live="polite">
            {/[a-z]/.test(password) ? '✓' : '○'} Lowercase letter
          </li>
          <li aria-live="polite">
            {/[0-9]/.test(password) ? '✓' : '○'} Number
          </li>
          <li aria-live="polite">
            {/[!@#$%^&*]/.test(password) ? '✓' : '○'} Special character (!@#$%^&*)
          </li>
        </ul>
      </div>
    </div>
  );
}

// ✅ GOOD: aria-live="polite" announces strength after user finishes typing
// aria-atomic="true" reads the full message, not just "fair" or "good"
// Screen reader hears: "Password is fair. Add special characters for better security."
// Feedback does not interrupt user's continued typing

// ❌ BAD: No aria-live or status role
// <div className="strength-feedback">Strength: fair</div>
// Screen reader never announces the feedback; strength only visible to sighted users
```

---

### Pattern 4: Multi-Step Form with Dynamic Field Visibility and aria-hidden

Demonstrates conditional field visibility in multi-step forms, using `aria-hidden="true"` to hide inactive steps from screen readers and proper focus management between steps.

```typescript
/**
 * Pattern 4: Multi-step form with aria-hidden and focus management.
 * 
 * Shows how to:
 * - Hide inactive form steps from screen readers with aria-hidden="true"
 * - Properly announce step changes to screen reader users
 * - Manage focus when transitioning between steps
 * - Validate and persist data across steps
 * 
 * WCAG criteria: 1.3.1, 2.4.3, 4.1.2
 */

import { useId, useRef } from 'react';

interface FormStep {
  id: string;
  title: string;
  fields: Array<{ name: string; label: string; type: string }>;
}

export function MultiStepForm() {
  const [currentStep, setCurrentStep] = React.useState(0);
  const [data, setData] = React.useState<Record<string, string>>({});
  const [errors, setErrors] = React.useState<Record<string, string>>({});

  const stepHeadingRef = useRef<HTMLHeadingElement>(null);

  const steps: FormStep[] = [
    {
      id: 'personal',
      title: 'Personal Information',
      fields: [
        { name: 'firstName', label: 'First Name', type: 'text' },
        { name: 'lastName', label: 'Last Name', type: 'text' },
      ],
    },
    {
      id: 'contact',
      title: 'Contact Information',
      fields: [
        { name: 'email', label: 'Email Address', type: 'email' },
        { name: 'phone', label: 'Phone Number', type: 'tel' },
      ],
    },
    {
      id: 'address',
      title: 'Billing Address',
      fields: [
        { name: 'street', label: 'Street Address', type: 'text' },
        { name: 'city', label: 'City', type: 'text' },
      ],
    },
  ];

  const step = steps[currentStep];

  const handleNextStep = () => {
    // Validate current step
    const stepErrors: Record<string, string> = {};
    step.fields.forEach((field) => {
      if (!data[field.name]) {
        stepErrors[field.name] = `${field.label} is required`;
      }
    });

    if (Object.keys(stepErrors).length > 0) {
      setErrors(stepErrors);
      return;
    }

    setErrors({});

    // Move to next step
    if (currentStep < steps.length - 1) {
      setCurrentStep(currentStep + 1);

      // Focus the step heading for screen reader announcement
      setTimeout(() => {
        stepHeadingRef.current?.focus();
      }, 0);
    }
  };

  const handlePreviousStep = () => {
    if (currentStep > 0) {
      setCurrentStep(currentStep - 1);

      // Focus the step heading for screen reader announcement
      setTimeout(() => {
        stepHeadingRef.current?.focus();
      }, 0);
    }
  };

  return (
    <div className="multi-step-form">
      {/* Progress indicator (informational) */}
      <div aria-label="Form progress" role="progressbar" aria-valuenow={currentStep + 1} aria-valuemin={1} aria-valuemax={steps.length}>
        Step {currentStep + 1} of {steps.length}: {step.title}
      </div>

      <form noValidate>
        {steps.map((s, index) => (
          <fieldset
            key={s.id}
            aria-hidden={index !== currentStep}
            style={{ display: index === currentStep ? 'block' : 'none' }}
          >
            <h2 ref={index === currentStep ? stepHeadingRef : null} tabIndex={-1}>
              {s.title}
            </h2>

            {s.fields.map((field) => {
              const fieldId = useId();
              const errorId = useId();
              const fieldError = errors[field.name];

              return (
                <div key={field.name} className="form-group">
                  <label htmlFor={fieldId}>{field.label}</label>
                  <input
                    id={fieldId}
                    type={field.type}
                    name={field.name}
                    value={data[field.name] || ''}
                    onChange={(e) =>
                      setData({ ...data, [field.name]: e.target.value })
                    }
                    required
                    aria-required="true"
                    aria-invalid={!!fieldError}
                    aria-describedby={fieldError ? errorId : undefined}
                  />
                  {fieldError && (
                    <div id={errorId} className="error-message" role="alert">
                      {fieldError}
                    </div>
                  )}
                </div>
              );
            })}
          </fieldset>
        ))}

        {/* Step navigation */}
        <div className="form-actions" role="toolbar" aria-label="Form navigation">
          <button
            type="button"
            onClick={handlePreviousStep}
            disabled={currentStep === 0}
          >
            Previous
          </button>

          {currentStep === steps.length - 1 ? (
            <button type="submit">Submit</button>
          ) : (
            <button type="button" onClick={handleNextStep}>
              Next
            </button>
          )}
        </div>
      </form>
    </div>
  );
}

// ✅ GOOD: aria-hidden="true" on inactive steps hides them from screen reader
// Focus moved to step heading (tabIndex=-1) when step changes
// Progress bar announces current step with aria-valuenow/aria-valuemax
// Screen reader hears: "Step 1 of 3: Personal Information, heading level 2"

// ❌ BAD: Inactive steps visible to screen reader
// <fieldset style={{ display: 'none' }}>
//   <input name="lastName" ... />  <!-- Screen reader still sees this -->
// </fieldset>
// Screen reader announces fields from all steps, causing confusion
```

---

## Constraints

### MUST DO

- Associate every `<input>`, `<select>`, and `<textarea>` with a `<label>` element using `for="input-id"` matching the input's `id` attribute, or use `aria-labelledby` if the label cannot be positioned adjacently
- Render form submission errors in a container with `role="alert"` or `aria-live="assertive"` at the top of the form to announce errors immediately to screen readers
- Link each form field error message to its input via `aria-describedby="error-id"` and set `aria-invalid="true"` on the input to signal validation failure
- Move focus to the first invalid field after form submission using `element.focus()` on the client side; do not rely on browser's default behavior
- Use semantic `<fieldset>` and `<legend>` elements to group related form fields (e.g., billing address fields); provide context via `aria-label` if `<legend>` is not sufficient
- Provide visible focus indicators on all form controls: minimum 2px solid outline with at least 3:1 contrast ratio against the background
- Test the form using keyboard navigation only (Tab, Shift+Tab, Enter, Arrow keys) to ensure all fields are reachable and focus order matches visual order
- Test with a screen reader (VoiceOver on macOS, NVDA on Windows) to verify labels, error messages, required status, and form purpose are announced correctly
- Use `aria-required="true"` on required fields and update the UI to reflect required status with a visual indicator (e.g., asterisk) paired with text

### MUST NOT DO

- Place placeholders as the only form field label; placeholder text disappears when the field is focused or has content, making it inaccessible to screen reader users
- Render error messages in a `<span>` or `<div>` without linking them to the input via `aria-describedby` or containing them in the input's `aria-label`
- Use JavaScript to trap focus inside a form without proper focus management; ensure users can Tab to all form controls and Escape does not unexpectedly close the form
- Create custom form controls (date picker, combobox, autocomplete) without implementing full keyboard navigation (Arrow keys to select, Enter to confirm, Escape to cancel)
- Use `display: none` or `visibility: hidden` on error messages; this hides them from screen readers. Instead, render the message and let CSS handle visibility if needed for layout reasons
- Assume that browser defaults handle accessibility; always explicitly set `id` attributes on inputs, use `<label for="id">`, and add ARIA attributes to convey state (required, invalid, readonly)
- Skip testing with actual screen readers; automated tools like axe DevTools cannot catch all accessibility issues (e.g., error message announcement timing, focus management)

---

## Output Template

When implementing this skill, your output must include:

1. **HTML/JSX Markup** — Complete form structure with semantic elements (`<form>`, `<fieldset>`, `<label>`), unique input `id` attributes, error containers with `role="alert"`, and all ARIA attributes (`aria-required`, `aria-invalid`, `aria-describedby`, `aria-live`)

2. **CSS Styling** — Focus indicator styles (minimum 2px outline with 3:1 contrast), error state styling (border color, text color), and live region visibility (ensure error messages are visible even if initially hidden)

3. **JavaScript/TypeScript** — Validation logic that runs server-side and client-side; focus management on form submission failure using `element.focus()`; dynamic content updates with `aria-live` regions

4. **Accessibility Audit Checklist** — Verify each WCAG success criterion is addressed:
   - [ ] 1.3.1 Info and Relationships: All inputs have associated labels
   - [ ] 3.3.1 Error Identification: Errors are identified and linked to fields
   - [ ] 2.4.3 Focus Order: Tab order follows visual order
   - [ ] 4.1.2 Name, Role, Value: All inputs have accessible names and states
   - [ ] 4.1.3 Status Messages: Errors and live feedback announced via aria-live

5. **Testing Instructions** — Step-by-step guide to verify:
   - Keyboard navigation: Tab through all fields; ensure focus is always visible
   - Screen reader testing: Use VoiceOver/NVDA to navigate form; confirm labels, errors, and required status are announced
   - Automated testing: Run axe DevTools or Lighthouse a11y audit; confirm zero critical violations

---

## Related Skills

(None currently identified.)

---

## WCAG References

- [1.3.1 Info and Relationships (A)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships): Labels must be programmatically associated with form controls via `<label for="id">` or `aria-labelledby`
- [3.3.1 Error Identification (A)](https://www.w3.org/WAI/WCAG22/Understanding/error-identification): Errors must be identified by color and an additional means (text, icon, or role); use `role="alert"` and link error messages via `aria-describedby`
- [2.4.3 Focus Order (A)](https://www.w3.org/WAI/WCAG22/Understanding/focus-order): Tab order must be logical and match the visual presentation; test with Tab/Shift+Tab
- [4.1.2 Name, Role, Value (A)](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value): All form controls must have an accessible name, role, and value; use `aria-required`, `aria-invalid`, and `aria-describedby`
- [4.1.3 Status Messages (AA)](https://www.w3.org/WAI/WCAG22/Understanding/status-messages): Changes in content must be announced to screen readers; use `aria-live="polite"` for feedback and `aria-live="assertive"` for errors

---

## Test Coverage Checklist

```
Form Accessibility Verification Checklist:

□ Keyboard Navigation
  □ Tab key moves focus through all inputs in visual order
  □ Shift+Tab moves focus backwards
  □ Enter submits form when focus on submit button
  □ Arrow keys (if applicable) navigate radio/checkbox groups
  □ Focus indicators always visible (minimum 2px outline, 3:1 contrast)

□ Label Association (WCAG 1.3.1)
  □ Every input has a unique id attribute
  □ Every input has an associated <label> with matching for="id"
  □ Placeholder text is NOT the only label
  □ Checkbox/radio labels are clickable

□ Error Handling (WCAG 3.3.1)
  □ Errors appear at form top with role="alert" or aria-live="assertive"
  □ Each error message linked to input via aria-describedby
  □ aria-invalid="true" set on invalid inputs
  □ Error messages describe the problem and how to fix it
  □ Focus moves to first invalid field on submission failure

□ Form State (WCAG 4.1.2)
  □ aria-required="true" on required fields
  □ aria-invalid state reflects validation status
  □ Disabled fields properly announced
  □ Read-only fields properly announced

□ Dynamic Updates (WCAG 4.1.3)
  □ aria-live="polite" used for non-critical feedback
  □ aria-live="assertive" used for error/urgent messages
  □ aria-atomic="true" on live regions that need full message read
  □ Live region updates announced without losing focus

□ Screen Reader Testing
  □ All labels announced when fields are focused
  □ Error messages announced after field or form submission
  □ Required status announced for required fields
  □ Form purpose clear from page context

□ Automated Testing
  □ axe DevTools: 0 critical violations
  □ axe DevTools: 0 serious violations
  □ Lighthouse a11y audit: 90+ score
  □ No false positives on accessibility tests
```

Attribution

paulpaspaulpas
View sourceSee grades on GitHubMore from paulpas →
SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Related Skills

Clean Code

Pragmatic coding standards - concise, direct, no over-engineering, no unnecessary comments

304955 votes

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

286712 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2222 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Writing Plans

Use when you have a spec or requirements for a multi-step task, before touching code

2927051 votes
View all in development →