
Form Accessibility
- 64 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
form-accessibility is a Claude Code skill that provides WCAG 2.2 AA-compliant patterns for accessible web forms, covering ARIA binding, focus management, and keyboard navigation.
About
form-accessibility is a Claude Code skill that supplies WCAG 2.2 AA patterns for building accessible web forms. It shows how to bind labels, hints, and error messages with ARIA, manage focus on errors and multi-step changes, and meet keyboard and target-size criteria. A developer loads it when implementing forms that must work for keyboard and screen-reader users. It is framework-agnostic and pairs with framework-specific form skills.
- WCAG 2.2 AA form patterns: ARIA binding, focus management, keyboard navigation
- Covers 11 mapped WCAG success criteria including new-in-2.2 rules
- Framework-agnostic accessible field, error, and fieldset patterns
Form Accessibility by the numbers
- 64 all-time installs (skills.sh)
- Ranked #1,196 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
form-accessibility capabilities & compatibility
- Capabilities
- form accessibility · focus management · aria binding · keyboard navigation
- Use cases
- ui design · frontend
What form-accessibility says it does
WCAG 2.2 AA compliance patterns for forms. Ensures forms work for keyboard users, screen reader users, and users with cognitive or motor disabilities.
On form submit with errors, focus first invalid field
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-accessibilityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 64 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Implement accessible web forms that pass WCAG 2.2 AA with correct ARIA, focus management, and keyboard support.
Who is it for?
Developers building forms that must meet WCAG 2.2 AA for keyboard and screen-reader users.
Skip if: Backend validation logic or non-form UI accessibility.
When should I use this skill?
Implementing accessible forms in any framework.
What you get
Forms with programmatic labels, announced errors, and managed focus that satisfy WCAG 2.2 AA.
- Accessible form field markup
- ARIA error-binding patterns
- Focus management logic
By the numbers
- Maps 11 WCAG success criteria in a critical-criteria table
- Minimum 24x24px target size rule cited
Files
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
// 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)
/* 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)
/* 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)
// ❌ 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)
// ❌ 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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
// 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)
// 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)
/* ❌ 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
/* Focus must have 3:1 contrast ratio */
input:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* For dark backgrounds */
input:focus {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
/* Never remove outline without replacement */
/* ❌ BAD */
input:focus {
outline: none;
}
/* ✅ GOOD: Custom focus style */
input:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5);
}Validation States (Colorblind-Friendly)
// Use icons + text, not just color
function ValidationIndicator({ state }: { state: 'valid' | 'invalid' | 'idle' }) {
if (state === 'idle') return null;
return (
<span className={`indicator ${state}`} aria-hidden="true">
{state === 'valid' && '✓'}
{state === 'invalid' && '✗'}
</span>
);
}Keyboard Navigation
Tab Order
// Natural tab order (no positive tabindex needed)
// ❌ BAD: Manual tab order
<input tabIndex={2} />
<input tabIndex={1} />
<input tabIndex={3} />
// ✅ GOOD: Natural DOM order
<input /> {/* tabIndex implicitly 0 */}
<input />
<input />Escape Key Handling
// Allow Escape to close dropdowns, cancel modals
function Modal({ onClose, children }) {
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
return <div role="dialog" aria-modal="true">{children}</div>;
}Enter to Submit
// Forms submit on Enter by default
// For buttons that shouldn't submit:
<button type="button" onClick={handleAction}>
Add Item
</button>
// For preventing Enter submit on specific fields:
<input
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Do something else
}
}}
/>Live Regions
Error Announcements
// Announce errors when they appear
<div aria-live="polite" aria-atomic="true" className="sr-only">
{errorCount > 0 && `${errorCount} errors in form`}
</div>
// Or use role="alert" for immediate announcement
{hasError && (
<span role="alert">{errorMessage}</span>
)}Loading States
// Announce loading state
<button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<span aria-hidden="true">Loading...</span>
<span className="sr-only">Submitting form, please wait</span>
</>
) : (
'Submit'
)}
</button>
// Or use aria-busy
<form aria-busy={isLoading}>
{/* ... */}
</form>Success Messages
// Announce successful submission
{isSuccess && (
<div role="status" aria-live="polite">
Form submitted successfully!
</div>
)}Screen Reader Only Content
/* Visually hidden but announced by screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Allow focus for skip links */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
}Testing Accessibility
Automated Tools
# axe-core (recommended)
npm install @axe-core/react
# In development
import React from 'react';
import ReactDOM from 'react-dom';
import axe from '@axe-core/react';
if (process.env.NODE_ENV !== 'production') {
axe(React, ReactDOM, 1000);
}Manual Testing Checklist
1. Keyboard only: Can you complete the form using only Tab, Enter, Space, and Arrow keys? 2. Screen reader: Does VoiceOver/NVDA announce labels, errors, and required status? 3. Zoom 200%: Is the form usable at 200% browser zoom? 4. High contrast: Is everything visible in Windows High Contrast mode? 5. Focus visible: Can you always see which element is focused?
Testing Script
// Automated accessibility test
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('form is accessible', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('error state is accessible', async () => {
const { container } = render(<LoginForm />);
// Trigger error
fireEvent.blur(screen.getByLabelText(/email/i));
const results = await axe(container);
expect(results).toHaveNoViolations();
});File Structure
form-accessibility/
├── SKILL.md
├── references/
│ ├── wcag-2.2-forms.md # Full WCAG criteria breakdown
│ └── aria-patterns.md # Complete ARIA reference
└── scripts/
├── aria-form-wrapper.tsx # Automatic ARIA binding
├── focus-manager.ts # Focus trap, error focus
├── error-announcer.ts # Live region management
└── accessibility-validator.ts # Runtime a11y checksReference
references/wcag-2.2-forms.md— Complete WCAG 2.2 criteria for formsreferences/aria-patterns.md— Detailed ARIA implementation patterns
{
"name": "form-accessibility",
"description": "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.",
"tags": [
"forms",
"web",
"html",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"form-react",
"form-vue",
"form-vanilla"
],
"last_reviewed_at": "2026-06-05",
"review_score": 66,
"relevance_tier": "B"
}
/**
* ARIA Form Wrapper
*
* React components that automatically handle ARIA bindings for form fields.
* Implements WCAG 2.2 AA compliance patterns.
*
* @module aria-form-wrapper
*/
import React, {
createContext,
useContext,
useId,
useMemo,
ReactNode,
InputHTMLAttributes,
TextareaHTMLAttributes,
SelectHTMLAttributes,
forwardRef
} from 'react';
// =============================================================================
// TYPES
// =============================================================================
export interface FormFieldContextValue {
/** Unique field ID */
fieldId: string;
/** ID for error message element */
errorId: string;
/** ID for hint/description element */
hintId: string;
/** Current error message (if any) */
error?: string;
/** Hint text (if any) */
hint?: string;
/** Whether field is required */
required?: boolean;
/** Whether field has been touched */
touched?: boolean;
/** Field label text */
label: string;
}
export interface FormFieldProps {
/** Field label (required for accessibility) */
label: string;
/** Unique name for the field */
name: string;
/** Error message to display */
error?: string;
/** Hint text to display below label */
hint?: string;
/** Whether field is required */
required?: boolean;
/** Whether field has been touched (for showing errors) */
touched?: boolean;
/** Child input element(s) */
children: ReactNode;
/** Additional class name */
className?: string;
/** Hide the label visually (still accessible to screen readers) */
hideLabel?: boolean;
}
export interface AccessibleInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'id'> {
/** Override auto-generated ID */
id?: string;
}
export interface AccessibleTextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'id'> {
/** Override auto-generated ID */
id?: string;
}
export interface AccessibleSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'id'> {
/** Override auto-generated ID */
id?: string;
children: ReactNode;
}
// =============================================================================
// CONTEXT
// =============================================================================
const FormFieldContext = createContext<FormFieldContextValue | null>(null);
/**
* Hook to access form field context
* Must be used within a FormField component
*/
export function useFormFieldContext(): FormFieldContextValue {
const context = useContext(FormFieldContext);
if (!context) {
throw new Error('useFormFieldContext must be used within a FormField');
}
return context;
}
// =============================================================================
// FORM FIELD WRAPPER
// =============================================================================
/**
* Accessible form field wrapper
*
* Provides automatic ARIA bindings for child inputs.
*
* @example
* ```tsx
* <FormField
* label="Email"
* name="email"
* error={errors.email}
* hint="We'll never share your email"
* required
* >
* <AccessibleInput type="email" autoComplete="email" />
* </FormField>
* ```
*/
export function FormField({
label,
name,
error,
hint,
required,
touched,
children,
className = '',
hideLabel = false
}: FormFieldProps) {
// Generate unique IDs
const uniqueId = useId();
const fieldId = `field-${name}-${uniqueId}`;
const errorId = `${fieldId}-error`;
const hintId = `${fieldId}-hint`;
// Determine visual state
const showError = touched && !!error;
const showValid = touched && !error;
// Build class names
const fieldClasses = [
'form-field',
showError && 'form-field--error',
showValid && 'form-field--valid',
required && 'form-field--required',
className
].filter(Boolean).join(' ');
// Context value for child inputs
const contextValue = useMemo<FormFieldContextValue>(() => ({
fieldId,
errorId,
hintId,
error,
hint,
required,
touched,
label
}), [fieldId, errorId, hintId, error, hint, required, touched, label]);
return (
<FormFieldContext.Provider value={contextValue}>
<div className={fieldClasses}>
{/* Label */}
<label
htmlFor={fieldId}
className={hideLabel ? 'sr-only' : 'form-field__label'}
>
{label}
{required && (
<>
<span className="form-field__required" aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</>
)}
</label>
{/* Hint (before input for screen reader flow) */}
{hint && (
<span id={hintId} className="form-field__hint">
{hint}
</span>
)}
{/* Input wrapper (for icons, addons) */}
<div className="form-field__input-wrapper">
{children}
{/* Visual state indicators */}
{showValid && (
<span className="form-field__icon form-field__icon--valid" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
)}
{showError && (
<span className="form-field__icon form-field__icon--error" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</span>
)}
</div>
{/* Error message (announced by screen readers) */}
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{error}
</span>
)}
</div>
</FormFieldContext.Provider>
);
}
// =============================================================================
// ACCESSIBLE INPUT
// =============================================================================
/**
* Accessible input that automatically binds ARIA attributes from FormField context
*
* @example
* ```tsx
* <FormField label="Email" name="email" error={error}>
* <AccessibleInput type="email" autoComplete="email" />
* </FormField>
* ```
*/
export const AccessibleInput = forwardRef<HTMLInputElement, AccessibleInputProps>(
function AccessibleInput(props, ref) {
const context = useFormFieldContext();
// Build aria-describedby
const describedBy = [
context.hint && context.hintId,
context.error && context.touched && context.errorId
].filter(Boolean).join(' ') || undefined;
return (
<input
ref={ref}
id={props.id || context.fieldId}
aria-invalid={context.touched && !!context.error}
aria-describedby={describedBy}
aria-required={context.required}
{...props}
/>
);
}
);
// =============================================================================
// ACCESSIBLE TEXTAREA
// =============================================================================
/**
* Accessible textarea that automatically binds ARIA attributes from FormField context
*/
export const AccessibleTextarea = forwardRef<HTMLTextAreaElement, AccessibleTextareaProps>(
function AccessibleTextarea(props, ref) {
const context = useFormFieldContext();
const describedBy = [
context.hint && context.hintId,
context.error && context.touched && context.errorId
].filter(Boolean).join(' ') || undefined;
return (
<textarea
ref={ref}
id={props.id || context.fieldId}
aria-invalid={context.touched && !!context.error}
aria-describedby={describedBy}
aria-required={context.required}
{...props}
/>
);
}
);
// =============================================================================
// ACCESSIBLE SELECT
// =============================================================================
/**
* Accessible select that automatically binds ARIA attributes from FormField context
*/
export const AccessibleSelect = forwardRef<HTMLSelectElement, AccessibleSelectProps>(
function AccessibleSelect({ children, ...props }, ref) {
const context = useFormFieldContext();
const describedBy = [
context.hint && context.hintId,
context.error && context.touched && context.errorId
].filter(Boolean).join(' ') || undefined;
return (
<select
ref={ref}
id={props.id || context.fieldId}
aria-invalid={context.touched && !!context.error}
aria-describedby={describedBy}
aria-required={context.required}
{...props}
>
{children}
</select>
);
}
);
// =============================================================================
// CHECKBOX FIELD
// =============================================================================
export interface CheckboxFieldProps {
/** Checkbox label */
label: ReactNode;
/** Field name */
name: string;
/** Error message */
error?: string;
/** Whether touched */
touched?: boolean;
/** Whether required */
required?: boolean;
/** Checkbox props */
inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
/** Additional class name */
className?: string;
}
/**
* Accessible checkbox field
*
* @example
* ```tsx
* <CheckboxField
* label="I accept the terms"
* name="acceptTerms"
* error={errors.acceptTerms}
* inputProps={register('acceptTerms')}
* />
* ```
*/
export function CheckboxField({
label,
name,
error,
touched,
required,
inputProps,
className = ''
}: CheckboxFieldProps) {
const uniqueId = useId();
const fieldId = `checkbox-${name}-${uniqueId}`;
const errorId = `${fieldId}-error`;
const showError = touched && !!error;
return (
<div className={`form-field form-field--checkbox ${showError ? 'form-field--error' : ''} ${className}`}>
<label className="form-field__checkbox-label">
<input
type="checkbox"
id={fieldId}
aria-invalid={showError}
aria-describedby={showError ? errorId : undefined}
aria-required={required}
{...inputProps}
/>
<span className="form-field__checkbox-text">
{label}
{required && <span className="sr-only">(required)</span>}
</span>
</label>
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{error}
</span>
)}
</div>
);
}
// =============================================================================
// RADIO GROUP
// =============================================================================
export interface RadioOption {
value: string;
label: string;
disabled?: boolean;
}
export interface RadioGroupProps {
/** Group legend (required for accessibility) */
legend: string;
/** Field name */
name: string;
/** Radio options */
options: RadioOption[];
/** Error message */
error?: string;
/** Whether touched */
touched?: boolean;
/** Whether required */
required?: boolean;
/** Currently selected value */
value?: string;
/** Change handler */
onChange?: (value: string) => void;
/** Additional props for radio inputs */
inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'name' | 'value'>;
/** Additional class name */
className?: string;
/** Layout direction */
direction?: 'horizontal' | 'vertical';
}
/**
* Accessible radio button group with fieldset/legend
*
* @example
* ```tsx
* <RadioGroup
* legend="Preferred contact method"
* name="contactMethod"
* options={[
* { value: 'email', label: 'Email' },
* { value: 'phone', label: 'Phone' }
* ]}
* error={errors.contactMethod}
* touched={touched.contactMethod}
* />
* ```
*/
export function RadioGroup({
legend,
name,
options,
error,
touched,
required,
value,
onChange,
inputProps,
className = '',
direction = 'vertical'
}: RadioGroupProps) {
const uniqueId = useId();
const errorId = `radio-${name}-${uniqueId}-error`;
const showError = touched && !!error;
return (
<fieldset
className={`form-field form-field--radio-group form-field--${direction} ${showError ? 'form-field--error' : ''} ${className}`}
aria-invalid={showError}
aria-describedby={showError ? errorId : undefined}
>
<legend className="form-field__legend">
{legend}
{required && (
<>
<span className="form-field__required" aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</>
)}
</legend>
<div className="form-field__radio-options">
{options.map((option) => (
<label key={option.value} className="form-field__radio-label">
<input
type="radio"
name={name}
value={option.value}
checked={value === option.value}
onChange={(e) => onChange?.(e.target.value)}
disabled={option.disabled}
aria-required={required}
{...inputProps}
/>
<span className="form-field__radio-text">{option.label}</span>
</label>
))}
</div>
{showError && (
<span id={errorId} className="form-field__error" role="alert">
{error}
</span>
)}
</fieldset>
);
}
// =============================================================================
// CSS (Include in your stylesheet)
// =============================================================================
export const formFieldCSS = `
/* Form Field Base */
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
.form-field__label {
font-weight: 500;
font-size: 0.875rem;
color: #374151;
}
.form-field__required {
color: #dc2626;
margin-left: 0.25rem;
}
.form-field__hint {
font-size: 0.75rem;
color: #6b7280;
}
.form-field__input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.form-field__input-wrapper input,
.form-field__input-wrapper textarea,
.form-field__input-wrapper select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-field__input-wrapper input:focus,
.form-field__input-wrapper textarea:focus,
.form-field__input-wrapper select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Error State */
.form-field--error .form-field__input-wrapper input,
.form-field--error .form-field__input-wrapper textarea,
.form-field--error .form-field__input-wrapper select {
border-color: #dc2626;
padding-right: 2.5rem;
}
.form-field--error .form-field__input-wrapper input:focus,
.form-field--error .form-field__input-wrapper textarea:focus,
.form-field--error .form-field__input-wrapper select:focus {
border-color: #dc2626;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
.form-field__error {
font-size: 0.75rem;
color: #dc2626;
display: flex;
align-items: center;
gap: 0.25rem;
}
.form-field__error::before {
content: "⚠";
}
/* Valid State */
.form-field--valid .form-field__input-wrapper input,
.form-field--valid .form-field__input-wrapper textarea,
.form-field--valid .form-field__input-wrapper select {
border-color: #059669;
padding-right: 2.5rem;
}
/* Icons */
.form-field__icon {
position: absolute;
right: 0.75rem;
pointer-events: none;
}
.form-field__icon--valid {
color: #059669;
}
.form-field__icon--error {
color: #dc2626;
}
/* Screen Reader Only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Checkbox */
.form-field--checkbox {
flex-direction: row;
align-items: flex-start;
gap: 0.5rem;
}
.form-field__checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
cursor: pointer;
}
.form-field__checkbox-label input {
width: 1rem;
height: 1rem;
margin-top: 0.125rem;
}
/* Radio Group */
.form-field--radio-group {
border: none;
padding: 0;
margin: 0 0 1rem 0;
}
.form-field__legend {
font-weight: 500;
font-size: 0.875rem;
color: #374151;
margin-bottom: 0.5rem;
}
.form-field__radio-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field--horizontal .form-field__radio-options {
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
.form-field__radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-field__radio-label input {
width: 1rem;
height: 1rem;
}
/* Target Size (WCAG 2.5.8) */
.form-field input[type="checkbox"],
.form-field input[type="radio"] {
min-width: 24px;
min-height: 24px;
}
`;
/**
* Focus Manager
*
* Utilities for managing focus in forms, including:
* - Focus on first error after submit
* - Focus trap for modals
* - Skip links
* - Step change focus (multi-step forms)
*
* @module focus-manager
*/
// =============================================================================
// TYPES
// =============================================================================
export interface FocusableElement extends HTMLElement {
focus(options?: FocusOptions): void;
}
export interface FocusTrapOptions {
/** Element to trap focus within */
container: HTMLElement;
/** Initial element to focus (defaults to first focusable) */
initialFocus?: HTMLElement | null;
/** Element to return focus to on deactivation */
returnFocus?: HTMLElement | null;
/** Callback when escape is pressed */
onEscape?: () => void;
/** Whether to allow focus to leave trap via click outside */
clickOutsideDeactivates?: boolean;
}
export interface FocusTrap {
/** Activate the focus trap */
activate: () => void;
/** Deactivate and return focus */
deactivate: () => void;
/** Check if trap is active */
isActive: () => boolean;
}
// =============================================================================
// FOCUSABLE ELEMENTS
// =============================================================================
/**
* Selector for all focusable elements
*/
export const FOCUSABLE_SELECTOR = [
'a[href]',
'area[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(', ');
/**
* Get all focusable elements within a container
*/
export function getFocusableElements(container: HTMLElement): FocusableElement[] {
const elements = container.querySelectorAll(FOCUSABLE_SELECTOR);
return Array.from(elements).filter((el) => {
// Filter out elements that are not visible
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}) as FocusableElement[];
}
/**
* Get the first focusable element in a container
*/
export function getFirstFocusable(container: HTMLElement): FocusableElement | null {
const elements = getFocusableElements(container);
return elements[0] || null;
}
/**
* Get the last focusable element in a container
*/
export function getLastFocusable(container: HTMLElement): FocusableElement | null {
const elements = getFocusableElements(container);
return elements[elements.length - 1] || null;
}
// =============================================================================
// FOCUS ON ERROR
// =============================================================================
/**
* Focus the first invalid field in a form
*
* @example
* ```tsx
* function handleSubmit(e) {
* e.preventDefault();
* const hasErrors = validate();
* if (hasErrors) {
* focusFirstError(formRef.current);
* return;
* }
* submit();
* }
* ```
*/
export function focusFirstError(form: HTMLFormElement | null): boolean {
if (!form) return false;
// Try aria-invalid first (most reliable)
const invalidField = form.querySelector('[aria-invalid="true"]') as FocusableElement | null;
if (invalidField) {
invalidField.focus({ preventScroll: false });
// Ensure field is scrolled into view
invalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
// Try :invalid pseudo-class (native validation)
const nativeInvalid = form.querySelector(':invalid:not(fieldset)') as FocusableElement | null;
if (nativeInvalid) {
nativeInvalid.focus({ preventScroll: false });
nativeInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
// Try error class (fallback)
const errorField = form.querySelector('.form-field--error input, .form-field--error select, .form-field--error textarea') as FocusableElement | null;
if (errorField) {
errorField.focus({ preventScroll: false });
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
return false;
}
/**
* Get all invalid fields in a form
*/
export function getInvalidFields(form: HTMLFormElement): FocusableElement[] {
const fields: FocusableElement[] = [];
// aria-invalid
form.querySelectorAll('[aria-invalid="true"]').forEach((el) => {
fields.push(el as FocusableElement);
});
// :invalid pseudo-class
form.querySelectorAll(':invalid:not(fieldset)').forEach((el) => {
if (!fields.includes(el as FocusableElement)) {
fields.push(el as FocusableElement);
}
});
return fields;
}
// =============================================================================
// FOCUS TRAP
// =============================================================================
/**
* Create a focus trap for modal dialogs and overlays
*
* @example
* ```tsx
* function Modal({ isOpen, onClose, children }) {
* const modalRef = useRef<HTMLDivElement>(null);
* const trapRef = useRef<FocusTrap | null>(null);
*
* useEffect(() => {
* if (isOpen && modalRef.current) {
* trapRef.current = createFocusTrap({
* container: modalRef.current,
* onEscape: onClose
* });
* trapRef.current.activate();
* }
*
* return () => trapRef.current?.deactivate();
* }, [isOpen, onClose]);
*
* return isOpen ? <div ref={modalRef}>{children}</div> : null;
* }
* ```
*/
export function createFocusTrap(options: FocusTrapOptions): FocusTrap {
const {
container,
initialFocus,
returnFocus,
onEscape,
clickOutsideDeactivates = false
} = options;
let active = false;
let previousFocus: HTMLElement | null = null;
function handleKeyDown(event: KeyboardEvent) {
if (!active) return;
if (event.key === 'Escape' && onEscape) {
event.preventDefault();
onEscape();
return;
}
if (event.key !== 'Tab') return;
const focusableElements = getFocusableElements(container);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Shift + Tab on first element -> go to last
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
// Tab on last element -> go to first
else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
function handleClickOutside(event: MouseEvent) {
if (!active || !clickOutsideDeactivates) return;
if (!container.contains(event.target as Node)) {
deactivate();
}
}
function activate() {
if (active) return;
active = true;
previousFocus = document.activeElement as HTMLElement;
// Add event listeners
document.addEventListener('keydown', handleKeyDown);
if (clickOutsideDeactivates) {
document.addEventListener('mousedown', handleClickOutside);
}
// Focus initial element
requestAnimationFrame(() => {
if (initialFocus) {
initialFocus.focus();
} else {
const firstFocusable = getFirstFocusable(container);
firstFocusable?.focus();
}
});
}
function deactivate() {
if (!active) return;
active = false;
// Remove event listeners
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
// Return focus
const focusTarget = returnFocus || previousFocus;
if (focusTarget && document.body.contains(focusTarget)) {
focusTarget.focus();
}
}
return {
activate,
deactivate,
isActive: () => active
};
}
// =============================================================================
// STEP CHANGE FOCUS
// =============================================================================
/**
* Focus a heading or container when changing steps in a multi-step form
*
* @example
* ```tsx
* function goToStep(stepNumber: number) {
* setCurrentStep(stepNumber);
* focusStepHeading(`step-${stepNumber}-heading`);
* }
*
* // In JSX:
* <h2 id="step-1-heading" tabIndex={-1}>Step 1: Contact Info</h2>
* ```
*/
export function focusStepHeading(headingId: string): void {
// Wait for React to render the new step
requestAnimationFrame(() => {
const heading = document.getElementById(headingId);
if (heading) {
// Make heading focusable if not already
if (!heading.hasAttribute('tabindex')) {
heading.setAttribute('tabindex', '-1');
}
heading.focus({ preventScroll: false });
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
}
/**
* Focus the first input in a step container
*/
export function focusFirstInput(containerId: string): void {
requestAnimationFrame(() => {
const container = document.getElementById(containerId);
if (container) {
const firstInput = getFirstFocusable(container);
if (firstInput) {
firstInput.focus({ preventScroll: false });
firstInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
});
}
// =============================================================================
// SCROLL MARGIN (WCAG 2.4.11 Focus Not Obscured)
// =============================================================================
/**
* Ensure focused elements are not obscured by sticky headers
*
* @example
* ```tsx
* // Call once on app mount
* setupScrollMargins({ topOffset: 80 }); // Height of sticky header
* ```
*/
export function setupScrollMargins(options: { topOffset?: number; bottomOffset?: number } = {}): void {
const { topOffset = 0, bottomOffset = 0 } = options;
// Add CSS for scroll-margin
const style = document.createElement('style');
style.textContent = `
input:focus,
select:focus,
textarea:focus,
button:focus,
[tabindex]:focus {
scroll-margin-top: ${topOffset}px;
scroll-margin-bottom: ${bottomOffset}px;
}
`;
document.head.appendChild(style);
}
// =============================================================================
// SKIP LINKS
// =============================================================================
/**
* Create a skip link for keyboard navigation
*
* @example
* ```tsx
* // At the top of your page
* <SkipLink targetId="main-content">Skip to main content</SkipLink>
* <SkipLink targetId="contact-form">Skip to contact form</SkipLink>
*
* // Target element
* <main id="main-content">...</main>
* <form id="contact-form">...</form>
* ```
*/
export function skipTo(targetId: string): void {
const target = document.getElementById(targetId);
if (target) {
// Make target focusable if needed
if (!target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '-1');
}
target.focus({ preventScroll: false });
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// =============================================================================
// REACT HOOKS
// =============================================================================
/**
* React hook for focus trap
*
* @example
* ```tsx
* function Modal({ isOpen, onClose, children }) {
* const containerRef = useFocusTrap({
* active: isOpen,
* onEscape: onClose
* });
*
* return isOpen ? <div ref={containerRef}>{children}</div> : null;
* }
* ```
*/
export interface UseFocusTrapOptions {
/** Whether trap is active */
active: boolean;
/** Callback when escape is pressed */
onEscape?: () => void;
/** Initial focus element ref */
initialFocusRef?: React.RefObject<HTMLElement>;
/** Return focus element ref */
returnFocusRef?: React.RefObject<HTMLElement>;
}
// Note: Actual React hook implementation would import from React
// This is the interface for documentation
/**
* React hook for focusing first error on form submit
*
* @example
* ```tsx
* function MyForm() {
* const { formRef, focusError } = useFormErrorFocus();
*
* function handleSubmit(e) {
* e.preventDefault();
* const errors = validate();
* if (Object.keys(errors).length > 0) {
* focusError();
* return;
* }
* submit();
* }
*
* return <form ref={formRef} onSubmit={handleSubmit}>...</form>;
* }
* ```
*/
export interface UseFormErrorFocusResult {
/** Ref to attach to form element */
formRef: React.RefObject<HTMLFormElement>;
/** Focus the first error field */
focusError: () => boolean;
/** Get all invalid field elements */
getInvalidFields: () => FocusableElement[];
}
// =============================================================================
// CSS FOR SKIP LINKS
// =============================================================================
export const skipLinkCSS = `
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: #000;
color: #fff;
text-decoration: none;
z-index: 100;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
`;
Related skills
FAQ
Which WCAG version does this skill target?
WCAG 2.2 AA, including criteria new in October 2023 such as 2.4.11 Focus Not Obscured and 2.5.8 Target Size.
Does it work outside React?
Yes. The description states it is for implementing accessible forms in any framework.