Claude
Skills
Sign in
Back

form-accessibility

Included with Lifetime
$97 forever

WCAG 2.2 AA compliance for forms, ARIA patterns, focus management, keyboard navigation, and screen reader support. Use when implementing accessible forms in any framework. The compliance foundation that ensures forms work for everyone.

Generalscripts

What this skill does


# Form Accessibility

WCAG 2.2 AA compliance patterns for forms. Ensures forms work for keyboard users, screen reader users, and users with cognitive or motor disabilities.

## Quick Start

```tsx
// Accessible form field pattern
<div className="form-field">
  {/* 1. Visible label (never placeholder-only) */}
  <label htmlFor="email">
    Email
    <span className="required" aria-hidden="true">*</span>
  </label>
  
  {/* 2. Hint text (separate from label) */}
  <span id="email-hint" className="hint">
    We'll send your confirmation here
  </span>
  
  {/* 3. Input with full ARIA binding */}
  <input
    id="email"
    type="email"
    autoComplete="email"
    aria-required="true"
    aria-invalid={hasError}
    aria-describedby={hasError ? "email-error email-hint" : "email-hint"}
  />
  
  {/* 4. Error message (announced by screen readers) */}
  {hasError && (
    <span id="email-error" className="error" role="alert">
      Please enter a valid email address
    </span>
  )}
</div>
```

## WCAG 2.2 Form Requirements

### Critical Criteria

| Criterion | Level | Requirement | Implementation |
|-----------|-------|-------------|----------------|
| 1.3.1 Info & Relationships | A | Structure conveyed programmatically | `<label>`, `<fieldset>`, `aria-describedby` |
| 1.3.5 Identify Input Purpose | AA | Input purpose identifiable | `autocomplete` attributes |
| 2.1.1 Keyboard | A | All functionality via keyboard | Tab order, focus management |
| 2.4.6 Headings & Labels | AA | Labels describe purpose | Descriptive, visible labels |
| 2.4.11 Focus Not Obscured | AA | Focus not hidden by other content | Scroll behavior, sticky elements |
| 2.5.8 Target Size | AA | 24×24px minimum touch target | Button/input sizing |
| 3.3.1 Error Identification | A | Errors identified and described | `aria-invalid`, error messages |
| 3.3.2 Labels or Instructions | A | Labels provided | Visible labels, not just placeholders |
| 3.3.3 Error Suggestion | AA | Suggestions for fixing errors | Actionable error messages |
| 3.3.7 Redundant Entry | A | Don't re-ask for info already provided | Form state management |
| 3.3.8 Accessible Authentication | AA | No cognitive function tests | No CAPTCHAs requiring text recognition |

### New in WCAG 2.2 (October 2023)

**2.4.11 Focus Not Obscured (AA)**
```css
/* Ensure focus is never hidden by sticky headers */
.sticky-header {
  position: sticky;
  top: 0;
}

input:focus {
  /* Browser should scroll input into view above sticky elements */
  scroll-margin-top: 80px; /* Height of sticky header */
}
```

**2.5.8 Target Size (AA)**
```css
/* Minimum 24×24px touch targets */
button,
input[type="submit"],
input[type="checkbox"],
input[type="radio"] {
  min-width: 24px;
  min-height: 24px;
}

/* Better: 44×44px for comfortable touch */
.touch-friendly {
  min-width: 44px;
  min-height: 44px;
}
```

**3.3.7 Redundant Entry (A)**
```tsx
// ❌ BAD: Asking for email twice
<input name="email" />
<input name="confirmEmail" />

// ✅ GOOD: Ask once, show confirmation
<input name="email" />
<p>Confirmation will be sent to: {email}</p>
```

**3.3.8 Accessible Authentication (AA)**
```tsx
// ❌ BAD: CAPTCHA requiring text recognition
<img src="captcha.png" alt="Enter the text shown" />

// ✅ GOOD: Alternative verification methods
<button type="button" onClick={sendVerificationEmail}>
  Send verification code to email
</button>
```

## ARIA Patterns

### Error Message Binding

```tsx
// Pattern: aria-describedby links input to error
<input
  id="email"
  aria-invalid={hasError ? "true" : "false"}
  aria-describedby={hasError ? "email-error" : undefined}
/>

{hasError && (
  <span id="email-error" role="alert">
    {errorMessage}
  </span>
)}
```

### Multiple Descriptions

```tsx
// Pattern: Combine hint + error in aria-describedby
<input
  id="password"
  aria-describedby={[
    "password-hint",
    hasError && "password-error"
  ].filter(Boolean).join(" ")}
/>

<span id="password-hint">Must be at least 8 characters</span>
{hasError && <span id="password-error" role="alert">{error}</span>}
```

### Required Fields

```tsx
// Pattern: Announce required status
<label htmlFor="name">
  Name
  <span className="required" aria-hidden="true">*</span>
  {/* Visual indicator hidden from SR, aria-required announces it */}
</label>

<input
  id="name"
  aria-required="true"
/>

// Alternative: Required in label (simpler)
<label htmlFor="name">Name (required)</label>
<input id="name" required />
```

### Field Groups

```tsx
// Pattern: fieldset + legend for related fields
<fieldset>
  <legend>Shipping Address</legend>
  
  <label htmlFor="street">Street</label>
  <input id="street" autoComplete="street-address" />
  
  <label htmlFor="city">City</label>
  <input id="city" autoComplete="address-level2" />
</fieldset>
```

### Radio/Checkbox Groups

```tsx
// Pattern: fieldset groups options, legend is the question
<fieldset>
  <legend>Preferred contact method</legend>
  
  <label>
    <input type="radio" name="contact" value="email" />
    Email
  </label>
  
  <label>
    <input type="radio" name="contact" value="phone" />
    Phone
  </label>
</fieldset>
```

## Focus Management

### Focus on First Error

```tsx
// On form submit with errors, focus first invalid field
function handleSubmit(e: FormEvent) {
  e.preventDefault();
  
  const firstError = formRef.current?.querySelector('[aria-invalid="true"]');
  if (firstError) {
    (firstError as HTMLElement).focus();
    return;
  }
  
  // Submit if valid
  submitForm();
}
```

### Focus on Step Change (Multi-step)

```tsx
// Move focus to step heading when changing steps
function goToStep(stepNumber: number) {
  setCurrentStep(stepNumber);
  
  // Wait for render, then focus
  requestAnimationFrame(() => {
    const heading = document.getElementById(`step-${stepNumber}-heading`);
    heading?.focus();
  });
}

// Heading must be focusable
<h2 id="step-2-heading" tabIndex={-1}>Shipping Address</h2>
```

### Skip Links

```tsx
// Allow skipping to form
<a href="#main-form" className="skip-link">
  Skip to form
</a>

<form id="main-form">
  {/* Form content */}
</form>

// CSS for skip link
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}
```

### Focus Trap (Modals)

```tsx
// Keep focus within modal form
function FocusTrap({ children }) {
  const trapRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    const trap = trapRef.current;
    if (!trap) return;
    
    const focusableElements = trap.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
    
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;
      
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
    
    trap.addEventListener('keydown', handleKeyDown);
    firstElement?.focus();
    
    return () => trap.removeEventListener('keydown', handleKeyDown);
  }, []);
  
  return <div ref={trapRef}>{children}</div>;
}
```

## Color & Contrast

### Error States (Colorblind-Safe)

```css
/* ❌ BAD: Color only */
.error {
  border-color: red;
}

/* ✅ GOOD: Color + icon + text */
.field-error {
  border-color: #dc2626;
  border-width: 2px;
}

.field-error::after {
  content: "";
  background-image: url("data:image/svg+xml,..."); /* Error icon */
}

.error-message {
  color: #dc2626;
  font-weight: 500;
}

.error-message::before {
  content: "⚠ "; /* Text indicator */
}
```

### Focus Indicators

```css
/* Focus must have 3:1 contrast ratio */
input:focus {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

/* For dark backgrounds */
input:foc

Related in General