Form Accessibility
apps/docs/src/content/docs/components/forms/accessibility Click to copy apps/docs/src/content/docs/components/forms/accessibility Form Accessibility
Section titled “Form Accessibility”Forms are the most critical user interaction pattern in healthcare applications. They’re how patients schedule appointments, clinicians enter medical records, and administrators manage systems. A single accessibility failure in a form can prevent a user from accessing essential healthcare services.
This guide covers everything you need to build accessible forms with web components: ARIA roles and attributes, label association patterns, error announcement strategies, required field indicators, field descriptions, live regions for validation, and healthcare-specific considerations. The HELiX accessibility posture is WCAG 2.2 AAA on the P0 component surface (per packages/hx-library/aaa-verdicts.json) with AA baseline elsewhere; healthcare regulatory baselines vary by jurisdiction and contract — Section 504 of the Rehabilitation Act currently references WCAG 2.1 AA for federally funded U.S. healthcare providers, and the HHS rule timelines extend into 2027–2028 — so calibrate the legal floor against your specific obligations.
Reading note: Several recipes below reach beyond the current shipped contract — the table of contents lists sections that were never written, some per-component snippets describe behavior the source doesn’t implement (e.g.
hx-text-inputdoes not setaria-requireddirectly because the nativerequiredattribute already maps to that semantic; the error live-region pattern varies per component rather than being one universal “container is always present” model; thehx-switchsnippet shows the wrong internal control). Inline corrections call out the highest-impact mismatches; treat the rest as a pattern catalog you adapt against each component’s CEM andAAA-AUDIT.md.
Table of Contents
Section titled “Table of Contents”- Why Form Accessibility Matters
- ARIA Roles and Attributes in Forms
- Label Association Patterns
- Error Announcement with Live Regions
- Required Field Indicators
- Field Descriptions with aria-describedby
- Validation and Live Regions
- Shadow DOM Accessibility Challenges
- Keyboard Navigation in Forms
- Screen Reader Testing
- Healthcare Compliance (WCAG 2.2 AAA on P0 / AA baseline elsewhere)
- HELiX Form Accessibility Patterns
- Real-World Examples
- Common Accessibility Failures
- Testing Checklist
Why Form Accessibility Matters
Section titled “Why Form Accessibility Matters”Forms are inherently interactive and stateful. They collect user input, validate it, display errors, and submit data. Every step of this process must be perceivable, operable, understandable, and robust for users with disabilities.
The Cost of Inaccessible Forms
Section titled “The Cost of Inaccessible Forms”In healthcare applications, inaccessible forms create barriers that can:
- Prevent patients from scheduling appointments — If a date picker isn’t keyboard accessible or doesn’t announce to screen readers
- Block medication refill requests — If error messages don’t announce or required fields aren’t marked
- Lock users out of account creation — If password requirements aren’t communicated or validation is visual-only
- Cause medical errors — If clinicians can’t navigate forms efficiently or validation feedback is unclear
Legal and Regulatory Requirements
Section titled “Legal and Regulatory Requirements”Healthcare organizations face:
- WCAG 2.2 AAA on the HELiX P0 surface / AA baseline elsewhere — meets or exceeds the WCAG 2.1 AA floor referenced by ADA, Section 508 / Section 504, and international accessibility laws
- HIPAA security — Accessible forms must also be secure (no ARIA attributes exposing PHI)
- Meaningful Use — EHR incentive programs require accessible patient portals
- Legal liability — Inaccessible forms can trigger lawsuits, OCR complaints, and audits
Key WCAG Success Criteria for Forms (WCAG 2.2 / 2.1 superset)
Section titled “Key WCAG Success Criteria for Forms (WCAG 2.2 / 2.1 superset)”Forms must meet these success criteria (minimum AA level):
| Criterion | Level | Requirement |
|---|---|---|
| 1.3.1 Info and Relationships | A | Form structure conveyed programmatically (labels, fieldsets, etc.) |
| 1.3.5 Identify Input Purpose | AA | Autocomplete attributes for common fields |
| 2.1.1 Keyboard | A | All form controls operable via keyboard |
| 2.4.6 Headings and Labels | AA | Form labels are descriptive |
| 2.5.3 Label in Name | A | Accessible name includes visible label |
| 3.2.2 On Input | A | No context changes on input (e.g., auto-submit) |
| 3.3.1 Error Identification | A | Errors identified and described in text |
| 3.3.2 Labels or Instructions | A | Labels/instructions provided for all inputs |
| 3.3.3 Error Suggestion | AA | Error correction suggestions provided |
| 3.3.4 Error Prevention (Legal, Financial, Data) | AA | Confirm/undo for critical submissions |
| 4.1.2 Name, Role, Value | A | All form controls have accessible names, roles, and states |
| 4.1.3 Status Messages | AA | Status messages (errors, success) announced via live regions |
ARIA Roles and Attributes in Forms
Section titled “ARIA Roles and Attributes in Forms”WAI-ARIA (Web Accessibility Initiative – Accessible Rich Internet Applications) provides attributes that communicate form control semantics to assistive technologies. When building web components, ARIA attributes bridge the gap between custom UI and accessible markup.
Core ARIA Attributes for Forms
Section titled “Core ARIA Attributes for Forms”| Attribute | Purpose | Values | Example |
|---|---|---|---|
aria-label | Provides an accessible name when no visible label exists | String | <input aria-label="Search"> |
aria-labelledby | Associates element with visible label(s) by ID | ID reference(s) | <input aria-labelledby="label-1 label-2"> |
aria-describedby | Associates element with description/help text | ID reference(s) | <input aria-describedby="help-1 error-1"> |
aria-required | Indicates field is required | true / false | <input aria-required="true"> |
aria-invalid | Indicates field has validation error | true / false / grammar / spelling | <input aria-invalid="true"> |
aria-errormessage | Points to error message element | ID reference | <input aria-errormessage="error-1"> |
aria-live | Announces dynamic content changes | polite / assertive / off | <div aria-live="polite"> |
aria-atomic | Announces entire region vs. changed nodes | true / false | <div aria-live="polite" aria-atomic="true"> |
aria-relevant | What changes trigger announcements | additions / removals / text / all | <div aria-live="polite" aria-relevant="additions text"> |
Native HTML First, ARIA Second
Section titled “Native HTML First, ARIA Second”Critical principle: Use native HTML elements and attributes first. Only add ARIA when native HTML is insufficient.
<!-- ✅ GOOD: Native HTML (no ARIA needed) --><label for="email">Email</label><input type="email" id="email" required />
<!-- ❌ BAD: ARIA duplicating native semantics --><label for="email">Email</label><input type="email" id="email" required aria-required="true" role="textbox" />However, web components often need ARIA because:
- Shadow DOM boundaries —
aria-labelledbyandaria-describedbydon’t cross shadow boundaries - Custom UI patterns — Components like switches, toggles, and custom selects need ARIA roles
- Dynamic validation — Error states and live regions require ARIA
ARIA Roles for Form Controls
Section titled “ARIA Roles for Form Controls”Most form components use native HTML elements (no role needed), but custom controls require explicit roles:
| Component Type | Native Element | Custom Element ARIA Role |
|---|---|---|
| Text input | <input type="text"> | role="textbox" (if using <div>) |
| Checkbox | <input type="checkbox"> | role="checkbox" |
| Radio button | <input type="radio"> | role="radio" |
| Select/dropdown | <select> | role="combobox" or role="listbox" |
| Button | <button> | role="button" |
| Switch (toggle) | <input type="checkbox"> + styles | role="switch" |
| Slider | <input type="range"> | role="slider" |
HELiX approach: All components wrap native HTML elements (no custom roles needed):
// hx-text-input: wraps <input> (native semantics preserved)render() { return html`<input type="text" />`;}
// hx-checkbox: wraps <input type="checkbox"> (native semantics preserved)render() { return html`<input type="checkbox" />`;}
// hx-switch: wraps <input type="checkbox"> + role="switch"render() { return html`<input type="checkbox" role="switch" />`;}aria-label vs. aria-labelledby vs. Native Labels
Section titled “aria-label vs. aria-labelledby vs. Native Labels”1. Native <label> (Best)
Section titled “1. Native <label> (Best)”<label for="username">Username</label> <input id="username" type="text" name="username" />Pros:
- Works everywhere (no ARIA needed)
- Clickable label focuses input
- Screen readers announce automatically
- Cross-browser support
Cons:
- Requires light DOM association (doesn’t cross shadow boundaries)
2. aria-labelledby (Good for Shadow DOM)
Section titled “2. aria-labelledby (Good for Shadow DOM)”<div id="label-username">Username</div><input aria-labelledby="label-username" type="text" name="username" />Pros:
- Works across shadow boundaries (if label and input are in same shadow root)
- Can reference multiple IDs:
aria-labelledby="label-1 label-2" - More flexible than
for/idassociation
Cons:
- Label not clickable (requires custom focus handling)
- Doesn’t work across shadow boundaries (label in light DOM, input in shadow DOM)
3. aria-label (Use Sparingly)
Section titled “3. aria-label (Use Sparingly)”<input aria-label="Username" type="text" name="username" />Pros:
- Simple, no ID management
- Works anywhere
Cons:
- No visible label (fails WCAG 2.1 AA unless label exists elsewhere)
- Not translatable (hardcoded in markup)
- Screen readers ignore visible text if
aria-labelpresent - Can’t use for complex labels (no rich content)
When to use:
- Icon-only buttons (
<button aria-label="Close">×</button>) - Inputs with visual context but no explicit label (search icon in header)
- Never use when a visible label exists (use
aria-labelledbyinstead)
aria-required vs. HTML required
Section titled “aria-required vs. HTML required”Use both for maximum compatibility:
<input type="text" required aria-required="true" />Why both?
required— Native HTML validation, browser UI,:requiredCSS selectoraria-required="true"— Screen reader announcement (“required, edit text”)
Screen reader behavior:
- VoiceOver (macOS): “Username, required, edit text”
- NVDA (Windows): “Username, edit, required, blank”
- JAWS (Windows): “Username, edit, required”
HELiX pattern:
// hx-text-inputrender() { return html` <input type="text" ?required=${this.required} aria-required=${this.required ? 'true' : nothing} /> `;}aria-invalid and Validation States
Section titled “aria-invalid and Validation States”Use aria-invalid="true" to indicate validation errors:
<!-- Invalid state --><input type="email" aria-invalid="true" aria-describedby="email-error" /><div id="email-error" role="alert">Please enter a valid email address.</div>
<!-- Valid state (omit aria-invalid) --><input type="email" />Key principles:
- Omit
aria-invalidwhen valid — Usenothingin Lit (notaria-invalid="false") - Add
aria-invalid="true"on error — Only when user attempts submission or field loses focus - Link to error message — Use
aria-describedbyto point to error text - Announce errors via live regions — Use
role="alert"oraria-live="polite"
When to set aria-invalid:
- On blur — After user leaves field (most common)
- On submit — After form submission attempt
- On input — For real-time validation (use sparingly, can be disruptive)
HELiX pattern:
// hx-text-inputrender() { const hasError = !!this.error;
return html` <input type="text" aria-invalid=${hasError ? 'true' : nothing} aria-describedby=${hasError ? this._errorId : nothing} /> ${hasError ? html` <div id=${this._errorId} role="alert" aria-live="polite"> ${this.error} </div> ` : nothing} `;}aria-errormessage (Modern Alternative)
Section titled “aria-errormessage (Modern Alternative)”aria-errormessage is a more explicit way to associate errors (vs. aria-describedby):
<input type="email" aria-invalid="true" aria-errormessage="email-error" /><div id="email-error">Please enter a valid email address.</div>Differences from aria-describedby:
- Semantic clarity — Explicitly marks element as an error message
- Screen reader behavior — Some screen readers announce differently (“Error: …” vs. “Described by…”)
- Browser support — Newer (Chrome 90+, Firefox 93+, Safari 16+)
HELiX approach: Use aria-describedby for broader compatibility (works in older browsers).
Label Association Patterns
Section titled “Label Association Patterns”Labels are the most critical accessibility feature for forms. Every input must have an accessible name that screen readers can announce.
Pattern 1: Native Label with for/id (Best)
Section titled “Pattern 1: Native Label with for/id (Best)”<label for="username">Username</label> <input id="username" type="text" name="username" />Pros:
- Standard HTML, works everywhere
- Clickable label focuses input
- Screen readers announce label automatically
Cons:
- Doesn’t work across shadow boundaries
When to use:
- Light DOM forms (traditional HTML)
- Server-side rendered forms (Drupal, Rails, etc.)
Pattern 2: Label Wrapping Input
Section titled “Pattern 2: Label Wrapping Input”<label> Username <input type="text" name="username" /></label>Pros:
- No ID management needed
- Clickable label focuses input
Cons:
- Doesn’t work across shadow boundaries
- Harder to style (label and input in same container)
Pattern 3: aria-labelledby (Shadow DOM)
Section titled “Pattern 3: aria-labelledby (Shadow DOM)”When the label and input are in the same shadow root:
<div id="label-username" part="label">Username</div><input id="input-username" part="input" type="text" aria-labelledby="label-username" />Pros:
- Works within shadow DOM
- Can reference multiple labels:
aria-labelledby="label-1 label-2"
Cons:
- Label not clickable (requires custom focus handling)
- Doesn’t work across shadow boundaries (label in light DOM, input in shadow DOM)
Pattern 4: aria-label (Use Sparingly)
Section titled “Pattern 4: aria-label (Use Sparingly)”<input type="text" aria-label="Username" />Use only when:
- No visible label exists (icon-only buttons, search inputs with placeholder)
- Visible label is insufficient (icon buttons needing clarification)
Don’t use when:
- A visible label exists (use
aria-labelledbyor native<label>instead) - Label needs to be translatable (use native labels)
Pattern 5: Slotted Labels (HELiX Pattern)
Section titled “Pattern 5: Slotted Labels (HELiX Pattern)”Web components with slotted labels inside the shadow DOM:
<!-- Light DOM usage --><hx-text-input name="username"> <label slot="label">Username</label></hx-text-input>
<!-- Shadow DOM implementation --><div part="field"> <slot name="label"></slot> <input id="${this._inputId}" type="text" /></div>Challenge: aria-labelledby doesn’t work across shadow boundaries. The input (in shadow DOM) can’t reference the label (in light DOM).
Solution: Use aria-label with the slotted text content, or keep label and input in the same shadow root.
HELiX approach:
// hx-text-input: Label and input in same shadow rootrender() { return html` <div part="field"> <!-- Built-in label (shadow DOM) --> ${this.label ? html` <label part="label" for=${this._inputId}>${this.label}</label> ` : nothing}
<!-- Slotted label (light DOM) --> <slot name="label" @slotchange=${this._handleLabelSlotChange}></slot>
<!-- Input (shadow DOM) --> <input id=${this._inputId} type="text" aria-labelledby=${this._hasLabelSlot ? `${this._inputId}-slotted-label` : nothing} /> </div> `;}
private _handleLabelSlotChange(e: Event): void { const slot = e.target as HTMLSlotElement; this._hasLabelSlot = slot.assignedElements().length > 0;
// Assign ID to slotted label for aria-labelledby if (this._hasLabelSlot) { const slottedLabel = slot.assignedElements()[0]; if (slottedLabel && !slottedLabel.id) { slottedLabel.id = `${this._inputId}-slotted-label`; } }
this.requestUpdate();}Key technique: Assign an ID to the slotted label element, then reference it with aria-labelledby from the shadow DOM input. This works because both are rendered in the same document (even though one is slotted).
Multiple Labels (aria-labelledby with Multiple IDs)
Section titled “Multiple Labels (aria-labelledby with Multiple IDs)”<div id="form-heading">Account Information</div><div id="username-label">Username</div><input aria-labelledby="form-heading username-label" type="text" />Screen reader announcement: “Account Information, Username, edit text”
Use case: Forms with section headings where context is needed (“Billing Address, Street Address, edit text”).
Labels with Required Indicators
Section titled “Labels with Required Indicators”<label for="email"> Email <span aria-hidden="true">*</span></label><input id="email" type="email" required aria-required="true" />Key points:
- Visual indicator (
*) isaria-hidden="true"(not announced) - Semantic required state is conveyed via
required+aria-required="true" - Screen reader announcement: “Email, required, edit text” (not “Email asterisk, required, edit text”)
Why aria-hidden="true" on *?
- Screen readers announce “required” from the
requiredattribute - Visual
*is redundant for screen reader users - Announcing “asterisk” adds noise
HELiX pattern:
// hx-text-inputrender() { return html` <label part="label" for=${this._inputId}> ${this.label} ${this.required ? html` <span class="field__required-marker" aria-hidden="true">*</span> ` : nothing} </label> <input id=${this._inputId} type="text" ?required=${this.required} aria-required=${this.required ? 'true' : nothing} /> `;}Error Announcement with Live Regions
Section titled “Error Announcement with Live Regions”Error messages must be announced to screen reader users. Visual error indicators (red borders, error icons) are invisible to screen readers without ARIA.
ARIA Live Regions
Section titled “ARIA Live Regions”Live regions announce dynamic content changes without moving focus:
<div aria-live="polite" aria-atomic="true"> <!-- Content here is announced when it changes --></div>Key attributes:
| Attribute | Values | Purpose |
|---|---|---|
aria-live | off / polite / assertive | Announce changes? |
aria-atomic | true / false | Announce entire region or just changes? |
aria-relevant | additions / removals / text / all | What changes to announce? |
aria-live Values
Section titled “aria-live Values”| Value | Behavior | Use Case |
|---|---|---|
off | No announcements (default) | Static content |
polite | Announce after current speech finishes | Form validation, success messages, status updates |
assertive | Interrupt current speech | Critical errors, urgent alerts (use sparingly) |
Best practice: Use polite for form validation errors. Use assertive only for critical system errors.
role=“alert” (Shorthand for aria-live)
Section titled “role=“alert” (Shorthand for aria-live)”role="alert" is equivalent to aria-live="assertive" aria-atomic="true":
<!-- These are equivalent --><div role="alert">Error message</div><div aria-live="assertive" aria-atomic="true">Error message</div>When to use role="alert":
- Critical errors (form submission failure, server errors)
- Urgent notifications (session timeout, data loss warning)
When to use aria-live="polite":
- Field validation errors (most common)
- Success messages (form submitted, data saved)
- Status updates (character count, search results count)
Error Announcement Pattern
Section titled “Error Announcement Pattern”Critical requirement: Error container must exist in the DOM before the error message appears. Screen readers won’t announce dynamically inserted live regions in some cases.
❌ BAD: Inserting Error Container Dynamically
Section titled “❌ BAD: Inserting Error Container Dynamically”// Don't do this: error container doesn't exist until error occursrender() { return html` <input type="email" /> ${this.error ? html` <div role="alert">${this.error}</div> ` : nothing} `;}Problem: Some screen readers (especially Safari + VoiceOver) won’t announce the error because the role="alert" element wasn’t in the DOM on page load.
✅ GOOD: Error Container Always Present
Section titled “✅ GOOD: Error Container Always Present”// Error container exists always (hidden when no error)render() { return html` <input type="email" aria-describedby=${this.error ? this._errorId : nothing} /> <div id=${this._errorId} role="alert" aria-live="polite" ?hidden=${!this.error} > ${this.error} </div> `;}Why this works:
- Error container exists in DOM on initial render
- Screen reader registers the live region
- When
hiddenattribute is removed and text changes, screen reader announces it aria-describedbylinks input to error message
Timing and Announcement Strategy
Section titled “Timing and Announcement Strategy”When to announce errors:
- On blur (recommended) — After user leaves field
- On submit — After form submission attempt
- On input (use sparingly) — Real-time validation (can be disruptive)
Pattern 1: Announce on Blur
Section titled “Pattern 1: Announce on Blur”export class HelixTextInput extends LitElement { @property({ type: String }) error = '';
private _handleBlur(): void { // Validate and set error if (this.required && !this.value) { this.error = 'This field is required.'; } else { this.error = ''; } }
render() { return html` <input type="text" @blur=${this._handleBlur} aria-invalid=${this.error ? 'true' : nothing} aria-describedby=${this.error ? this._errorId : nothing} /> <div id=${this._errorId} role="alert" aria-live="polite" ?hidden=${!this.error}> ${this.error} </div> `; }}Pattern 2: Announce on Submit
Section titled “Pattern 2: Announce on Submit”private _handleSubmit(e: Event): void { e.preventDefault();
// Validate all fields const firstInvalidField = this._validateAllFields();
if (firstInvalidField) { // Focus first invalid field firstInvalidField.focus();
// Error already announced via field's role="alert" } else { // Submit form this._submitForm(); }}Live Region Politeness Levels
Section titled “Live Region Politeness Levels”Polite (Recommended for Forms)
Section titled “Polite (Recommended for Forms)”<div aria-live="polite">Password must be at least 8 characters.</div>Behavior:
- Waits for screen reader to finish current announcement
- Doesn’t interrupt user
- Queues announcement
Use for:
- Field validation errors
- Success messages
- Character count updates
Assertive (Use Sparingly)
Section titled “Assertive (Use Sparingly)”<div aria-live="assertive">Critical error: Session expired. Please log in again.</div>Behavior:
- Interrupts current screen reader speech
- Announces immediately
- Can be disruptive
Use for:
- Critical system errors
- Data loss warnings
- Security alerts
HELiX Error Announcement Pattern
Section titled “HELiX Error Announcement Pattern”All HELiX form components use this pattern:
// hx-text-inputrender() { const hasError = !!this.error;
const describedBy = [ hasError ? this._errorId : null, this.helpText ? this._helpTextId : null, ].filter(Boolean).join(' ') || undefined;
return html` <input type="text" aria-invalid=${hasError ? 'true' : nothing} aria-describedby=${ifDefined(describedBy)} />
<!-- Error: role="alert" + aria-live="polite" --> <div id=${this._errorId} part="error" role="alert" aria-live="polite" ?hidden=${!hasError} > ${this.error} </div>
<!-- Help text: No live region (static) --> ${this.helpText && !hasError ? html` <div id=${this._helpTextId} part="help-text"> ${this.helpText} </div> ` : nothing} `;}Key features:
- Error container always present — Hidden with
?hidden=${!hasError}, not removed from DOM role="alert"+aria-live="polite"— Announces when error appears/changesaria-describedbylinking — Input references error message by IDaria-invalid="true"on error — Marks input as invalid- Help text hidden when error shown — Only error or help text shown (not both)
Required Field Indicators
Section titled “Required Field Indicators”Required fields must be marked both visually and semantically.
Visual Indicators
Section titled “Visual Indicators”Common patterns:
- Asterisk (
*) — Most common, culturally recognized - “Required” text — Explicit, no ambiguity
- Color — Red border or label (must not be only indicator, fails WCAG 1.4.1)
- Icon — With accessible label
Semantic Indicators
Section titled “Semantic Indicators”HTML/ARIA attributes:
requiredattribute — Native HTML validationaria-required="true"— Screen reader announcement
Pattern 1: Asterisk (Most Common)
Section titled “Pattern 1: Asterisk (Most Common)”<label for="email"> Email <span aria-hidden="true">*</span></label><input id="email" type="email" required aria-required="true" />Key points:
- Asterisk is
aria-hidden="true"(not announced) - Screen readers announce “Email, required, edit text”
- Visual users see “Email *”
Legend (once per form):
<p>Fields marked with <span aria-hidden="true">*</span> are required.</p>Pattern 2: “Required” Text
Section titled “Pattern 2: “Required” Text”<label for="email"> Email <span>(required)</span> </label><input id="email" type="email" required aria-required="true" />Screen reader announcement: “Email, required, edit text” (the required attribute takes precedence, so “(required)” in label is redundant but harmless).
Pros:
- Explicit, no cultural assumptions
- Works for users unfamiliar with
*convention
Cons:
- More verbose visually
Pattern 3: Optional Indicators (Inverse)
Section titled “Pattern 3: Optional Indicators (Inverse)”For forms where most fields are required, mark optional fields instead:
<label for="middle-name"> Middle Name <span>(optional)</span> </label><input id="middle-name" type="text" />Pros:
- Less visual clutter (fewer indicators)
- Clearer when most fields are required
Cons:
- Less common convention
HELiX Pattern (Asterisk)
Section titled “HELiX Pattern (Asterisk)”// hx-text-inputrender() { return html` <label part="label" for=${this._inputId}> ${this.label} ${this.required ? html` <span class="field__required-marker" aria-hidden="true">*</span> ` : nothing} </label>
<input id=${this._inputId} type="text" ?required=${this.required} aria-required=${this.required ? 'true' : nothing} /> `;}CSS styling:
.field__required-marker { color: var(--hx-color-error-500); margin-left: var(--hx-spacing-xs); font-weight: 700;}Required Field Groups (Fieldsets)
Section titled “Required Field Groups (Fieldsets)”For groups of fields where at least one is required:
<fieldset> <legend>Contact Method (at least one required)</legend>
<label for="email"> Email </label> <input id="email" type="email" name="contact-email" />
<label for="phone"> Phone </label> <input id="phone" type="tel" name="contact-phone" /></fieldset>Custom validation:
private _validateContactMethod(): void { const hasEmail = !!this.email; const hasPhone = !!this.phone;
if (!hasEmail && !hasPhone) { this.error = 'Please provide at least one contact method (email or phone).'; } else { this.error = ''; }}Field Descriptions with aria-describedby
Section titled “Field Descriptions with aria-describedby”aria-describedby associates form controls with descriptive text (help text, hints, character limits, format examples).
Basic Pattern
Section titled “Basic Pattern”<label for="password">Password</label><input id="password" type="password" aria-describedby="password-help" /><div id="password-help"> Must be at least 8 characters with one uppercase letter and one number.</div>Screen reader announcement: “Password, edit text, secure, Must be at least 8 characters with one uppercase letter and one number.”
Multiple Descriptions
Section titled “Multiple Descriptions”aria-describedby accepts multiple IDs (space-separated):
<label for="password">Password</label><input id="password" type="password" aria-describedby="password-help password-constraint" /><div id="password-help">Used to secure your account.</div><div id="password-constraint"> Must be at least 8 characters with one uppercase letter and one number.</div>Screen reader announcement: Concatenates all referenced text: “Used to secure your account. Must be at least 8 characters with one uppercase letter and one number.”
Help Text vs. Error Messages
Section titled “Help Text vs. Error Messages”Pattern: Show help text OR error message, not both simultaneously.
<label for="email">Email</label><input id="email" type="email" aria-describedby="${hasError ? 'email-error' : 'email-help'}" aria-invalid="${hasError ? 'true' : undefined}"/>
<!-- Help text (shown when no error) --><div id="email-help" ?hidden="${hasError}">We'll never share your email with third parties.</div>
<!-- Error message (shown when error) --><div id="email-error" role="alert" ?hidden="${!hasError}">Please enter a valid email address.</div>Why not both?
- Reduces verbosity (screen readers announce everything in
aria-describedby) - Focuses user on the problem (error) rather than guidance (help text)
- Error messages typically include corrective guidance
HELiX pattern:
// hx-text-inputrender() { const hasError = !!this.error;
// Build describedBy from error or help text (not both) const describedBy = [ hasError ? this._errorId : null, this.helpText && !hasError ? this._helpTextId : null, ].filter(Boolean).join(' ') || undefined;
return html` <input type="text" aria-describedby=${ifDefined(describedBy)} />
<div id=${this._errorId} role="alert" aria-live="polite" ?hidden=${!hasError}> ${this.error} </div>
${this.helpText && !hasError ? html` <div id=${this._helpTextId}>${this.helpText}</div> ` : nothing} `;}Character Count
Section titled “Character Count”<label for="tweet">Tweet</label><textarea id="tweet" maxlength="280" aria-describedby="tweet-count"></textarea><div id="tweet-count" aria-live="polite" aria-atomic="true"> <span id="char-remaining">280</span> characters remaining</div>Key points:
aria-live="polite"announces count changes as user typesaria-atomic="true"announces full message (“42 characters remaining”) not just the number- Update
char-remainingoninputevent
HELiX textarea pattern:
// hx-textarearender() { const remaining = this.maxlength ? this.maxlength - this.value.length : null;
return html` <textarea maxlength=${ifDefined(this.maxlength ?? undefined)} aria-describedby=${remaining !== null ? this._countId : nothing} ></textarea>
${remaining !== null ? html` <div id=${this._countId} aria-live="polite" aria-atomic="true"> ${remaining} character${remaining !== 1 ? 's' : ''} remaining </div> ` : nothing} `;}Unfinished sections: The earlier draft of this page reserved space here for “remaining sections following the same comprehensive structure” — those sections (Real-World Examples, the complete healthcare-compliance walkthrough) haven’t been written yet. The pattern catalog above is the working content; the missing sections are tracked as a follow-up.
Summary
Section titled “Summary”Form accessibility is non-negotiable in healthcare applications. Every form control must have:
- Accessible name — Label via
<label>,aria-labelledby, oraria-label - Validation state —
aria-invalid="true"when error present - Error announcement —
role="alert"+aria-atomic="true"for assertive errors;role="status"for polite live regions - Field description —
aria-describedbyfor help text and errors (both IDs can be present) - Required indication — native
required(HELiX form components rely on the native semantic; explicitaria-requiredis not added on top); paired with a visible indicator - Keyboard operability — All controls accessible via keyboard alone
- Focus indicator — Visible focus ring on all interactive elements
- Screen reader testing — Manually tested with NVDA and VoiceOver
HELiX posture: the 44 P0 components self-cert against WCAG 2.2 AAA via scripts/aaa-formal-audit.mjs (verdicts published in packages/hx-library/aaa-verdicts.json); WCAG 2.2 AA is the baseline elsewhere. Consumers retain responsibility for the larger context — label association in their own templates, page-level landmark structure, focus management around custom flows, and content/copywriting accessibility.
Sources and Further Reading
Section titled “Sources and Further Reading”- WCAG 2.1 AA Compliance Checklist
- WAI-ARIA Overview (W3C)
- WCAG 2.2 Techniques (W3C)
- Labeling Controls (W3C)
- ARIA21: Using aria-invalid (W3C)
- ARIA19: Using ARIA Live Regions (W3C)
- aria-invalid Attribute (MDN)
- aria-errormessage Attribute (MDN)
- Exposing Field Errors (Adrian Roselli)
- Form Error Communication (Harvard Digital Accessibility Services)