Skip to content
HELiX

Form Accessibility

apps/docs/src/content/docs/components/forms/accessibility Click to copy
Copied! apps/docs/src/content/docs/components/forms/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-input does not set aria-required directly because the native required attribute already maps to that semantic; the error live-region pattern varies per component rather than being one universal “container is always present” model; the hx-switch snippet 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 and AAA-AUDIT.md.

  1. Why Form Accessibility Matters
  2. ARIA Roles and Attributes in Forms
  3. Label Association Patterns
  4. Error Announcement with Live Regions
  5. Required Field Indicators
  6. Field Descriptions with aria-describedby
  7. Validation and Live Regions
  8. Shadow DOM Accessibility Challenges
  9. Keyboard Navigation in Forms
  10. Screen Reader Testing
  11. Healthcare Compliance (WCAG 2.2 AAA on P0 / AA baseline elsewhere)
  12. HELiX Form Accessibility Patterns
  13. Real-World Examples
  14. Common Accessibility Failures
  15. Testing Checklist

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.

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

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):

CriterionLevelRequirement
1.3.1 Info and RelationshipsAForm structure conveyed programmatically (labels, fieldsets, etc.)
1.3.5 Identify Input PurposeAAAutocomplete attributes for common fields
2.1.1 KeyboardAAll form controls operable via keyboard
2.4.6 Headings and LabelsAAForm labels are descriptive
2.5.3 Label in NameAAccessible name includes visible label
3.2.2 On InputANo context changes on input (e.g., auto-submit)
3.3.1 Error IdentificationAErrors identified and described in text
3.3.2 Labels or InstructionsALabels/instructions provided for all inputs
3.3.3 Error SuggestionAAError correction suggestions provided
3.3.4 Error Prevention (Legal, Financial, Data)AAConfirm/undo for critical submissions
4.1.2 Name, Role, ValueAAll form controls have accessible names, roles, and states
4.1.3 Status MessagesAAStatus messages (errors, success) announced via live regions

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.

AttributePurposeValuesExample
aria-labelProvides an accessible name when no visible label existsString<input aria-label="Search">
aria-labelledbyAssociates element with visible label(s) by IDID reference(s)<input aria-labelledby="label-1 label-2">
aria-describedbyAssociates element with description/help textID reference(s)<input aria-describedby="help-1 error-1">
aria-requiredIndicates field is requiredtrue / false<input aria-required="true">
aria-invalidIndicates field has validation errortrue / false / grammar / spelling<input aria-invalid="true">
aria-errormessagePoints to error message elementID reference<input aria-errormessage="error-1">
aria-liveAnnounces dynamic content changespolite / assertive / off<div aria-live="polite">
aria-atomicAnnounces entire region vs. changed nodestrue / false<div aria-live="polite" aria-atomic="true">
aria-relevantWhat changes trigger announcementsadditions / removals / text / all<div aria-live="polite" aria-relevant="additions text">

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:

  1. Shadow DOM boundariesaria-labelledby and aria-describedby don’t cross shadow boundaries
  2. Custom UI patterns — Components like switches, toggles, and custom selects need ARIA roles
  3. Dynamic validation — Error states and live regions require ARIA

Most form components use native HTML elements (no role needed), but custom controls require explicit roles:

Component TypeNative ElementCustom 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"> + stylesrole="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”
<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)
<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/id association

Cons:

  • Label not clickable (requires custom focus handling)
  • Doesn’t work across shadow boundaries (label in light DOM, input in shadow DOM)
<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-label present
  • 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-labelledby instead)

Use both for maximum compatibility:

<input type="text" required aria-required="true" />

Why both?

  • required — Native HTML validation, browser UI, :required CSS selector
  • aria-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-input
render() {
return html`
<input
type="text"
?required=${this.required}
aria-required=${this.required ? 'true' : nothing}
/>
`;
}

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:

  1. Omit aria-invalid when valid — Use nothing in Lit (not aria-invalid="false")
  2. Add aria-invalid="true" on error — Only when user attempts submission or field loses focus
  3. Link to error message — Use aria-describedby to point to error text
  4. Announce errors via live regions — Use role="alert" or aria-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-input
render() {
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 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).


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.)
<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)

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)
<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-labelledby or native <label> instead)
  • Label needs to be translatable (use native labels)

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 root
render() {
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”).

<label for="email">
Email
<span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />

Key points:

  • Visual indicator (*) is aria-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 required attribute
  • Visual * is redundant for screen reader users
  • Announcing “asterisk” adds noise

HELiX pattern:

// hx-text-input
render() {
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 messages must be announced to screen reader users. Visual error indicators (red borders, error icons) are invisible to screen readers without ARIA.

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:

AttributeValuesPurpose
aria-liveoff / polite / assertiveAnnounce changes?
aria-atomictrue / falseAnnounce entire region or just changes?
aria-relevantadditions / removals / text / allWhat changes to announce?
ValueBehaviorUse Case
offNo announcements (default)Static content
politeAnnounce after current speech finishesForm validation, success messages, status updates
assertiveInterrupt current speechCritical 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)

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 occurs
render() {
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.

// 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:

  1. Error container exists in DOM on initial render
  2. Screen reader registers the live region
  3. When hidden attribute is removed and text changes, screen reader announces it
  4. aria-describedby links input to error message

When to announce errors:

  1. On blur (recommended) — After user leaves field
  2. On submit — After form submission attempt
  3. On input (use sparingly) — Real-time validation (can be disruptive)
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>
`;
}
}
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();
}
}
<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
<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

All HELiX form components use this pattern:

// hx-text-input
render() {
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:

  1. Error container always present — Hidden with ?hidden=${!hasError}, not removed from DOM
  2. role="alert" + aria-live="polite" — Announces when error appears/changes
  3. aria-describedby linking — Input references error message by ID
  4. aria-invalid="true" on error — Marks input as invalid
  5. Help text hidden when error shown — Only error or help text shown (not both)

Required fields must be marked both visually and semantically.

Common patterns:

  1. Asterisk (*) — Most common, culturally recognized
  2. “Required” text — Explicit, no ambiguity
  3. Color — Red border or label (must not be only indicator, fails WCAG 1.4.1)
  4. Icon — With accessible label

HTML/ARIA attributes:

  1. required attribute — Native HTML validation
  2. aria-required="true" — Screen reader announcement
<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>
<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

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
// hx-text-input
render() {
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;
}

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 = '';
}
}

aria-describedby associates form controls with descriptive text (help text, hints, character limits, format examples).

<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.”

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.”

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-input
render() {
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}
`;
}
<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 types
  • aria-atomic="true" announces full message (“42 characters remaining”) not just the number
  • Update char-remaining on input event

HELiX textarea pattern:

// hx-textarea
render() {
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.


Form accessibility is non-negotiable in healthcare applications. Every form control must have:

  1. Accessible name — Label via <label>, aria-labelledby, or aria-label
  2. Validation statearia-invalid="true" when error present
  3. Error announcementrole="alert" + aria-atomic="true" for assertive errors; role="status" for polite live regions
  4. Field descriptionaria-describedby for help text and errors (both IDs can be present)
  5. Required indication — native required (HELiX form components rely on the native semantic; explicit aria-required is not added on top); paired with a visible indicator
  6. Keyboard operability — All controls accessible via keyboard alone
  7. Focus indicator — Visible focus ring on all interactive elements
  8. 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.