DOMAIN:ACCESSIBILITY:COMPONENT_PATTERNS¶
OWNER: julian (compliance), alexander (design) ALSO_USED_BY: floris, floor, antje UPDATED: 2026-03-26 SCOPE: accessible implementation patterns for common UI components REFERENCE: WAI-ARIA Authoring Practices Guide (APG) — w3.org/WAI/ARIA/apg/patterns/
COMPONENT_PATTERNS:PRINCIPLE¶
RULE_1: use native HTML elements before ARIA — no ARIA is better than bad ARIA RULE_2: every custom component must match the WAI-ARIA APG keyboard interaction pattern RULE_3: every custom component must be tested with NVDA and VoiceOver RULE_4: component implementations must be documented in DESIGN.md with accessibility notes
PATTERN:FORMS¶
FORM_FIELD_BASICS¶
RULE: every input must have a visible, associated label RULE: never use placeholder as the only label — it disappears on input RULE: group related fields with fieldset + legend RULE: mark required fields with aria-required="true" AND visual indicator RULE: describe constraints with aria-describedby
PATTERN — TEXT_INPUT:
<div class="field">
<label for="email">Email address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email"
autocomplete="email"
aria-required="true"
aria-describedby="email-hint email-error">
<p id="email-hint" class="hint">We will use this for login</p>
<p id="email-error" class="error" role="alert" hidden>
Please enter a valid email address
</p>
</div>
PATTERN — FIELD_GROUP:
<fieldset>
<legend>Billing address</legend>
<label for="street">Street</label>
<input type="text" id="street" autocomplete="street-address">
<label for="city">City</label>
<input type="text" id="city" autocomplete="address-level2">
<label for="postal">Postal code</label>
<input type="text" id="postal" autocomplete="postal-code">
</fieldset>
FORM_VALIDATION¶
RULE: display errors in text, not color alone RULE: associate errors with fields via aria-describedby RULE: use aria-invalid="true" on invalid fields RULE: announce errors with role="alert" or aria-live="assertive" RULE: move focus to first error field on form submission failure RULE: provide error summary at top of form for complex forms
PATTERN — ERROR_STATE:
<label for="phone">Phone number</label>
<input type="tel" id="phone"
aria-invalid="true"
aria-describedby="phone-error">
<p id="phone-error" class="error" role="alert">
Phone number must include country code (e.g., +31 6 12345678)
</p>
PATTERN — ERROR_SUMMARY:
<div role="alert" aria-labelledby="error-heading" tabindex="-1">
<h2 id="error-heading">There are 2 errors in this form</h2>
<ul>
<li><a href="#email">Email address is required</a></li>
<li><a href="#phone">Phone number format is invalid</a></li>
</ul>
</div>
SELECT / COMBOBOX¶
NATIVE_SELECT: use
PATTERN — NATIVE_SELECT:
<label for="country">Country</label>
<select id="country" name="country" autocomplete="country">
<option value="">Select a country</option>
<option value="NL">Netherlands</option>
<option value="DE">Germany</option>
<option value="BE">Belgium</option>
</select>
COMBOBOX_ARIA: - role="combobox" on the input - aria-expanded="true/false" based on list visibility - aria-controls pointing to the listbox ID - aria-activedescendant pointing to highlighted option - role="listbox" on the options container - role="option" on each option
KEYBOARD: - Down Arrow — open list, move to next option - Up Arrow — move to previous option - Enter — select highlighted option, close list - Escape — close list without selecting - Type ahead — filter options
PATTERN:MODAL_DIALOG¶
ARIA_REQUIREMENTS¶
ROLE: role="dialog" on the modal container MODAL: aria-modal="true" — tells assistive tech content behind is inert LABEL: aria-labelledby pointing to the modal title DESCRIPTION: aria-describedby pointing to modal description (optional)
FOCUS_MANAGEMENT¶
RULE: on open — move focus to first focusable element (or modal title if content is long) RULE: trap focus inside modal — Tab and Shift+Tab cycle within modal only RULE: on close — return focus to the element that triggered the modal RULE: Escape key closes the modal RULE: clicking backdrop closes the modal (optional but expected)
PATTERN:
<div role="dialog" aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc">
<h2 id="modal-title">Delete project?</h2>
<p id="modal-desc">
This action cannot be undone. All project data will be permanently removed.
</p>
<div class="modal-actions">
<button type="button" data-action="cancel">Cancel</button>
<button type="button" data-action="confirm">Delete project</button>
</div>
</div>
FOCUS_TRAP_IMPLEMENTATION¶
function trapFocus(modal: HTMLElement) {
const focusable = modal.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') {
closeModal(modal);
}
});
first.focus();
}
KEYBOARD: - Tab — cycle through focusable elements inside modal - Shift+Tab — reverse cycle - Escape — close modal - Enter/Space — activate buttons
TESTING: open modal → verify focus is inside modal TESTING: Tab to last element → Tab again → verify focus wraps to first element TESTING: press Escape → verify modal closes and focus returns to trigger TESTING: verify content behind modal is inert (not focusable)
PATTERN:NAVIGATION_MENU¶
LANDMARK¶
RULE: wrap primary navigation in
PATTERN — SKIP_LINK:
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav aria-label="Main navigation">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</nav>
<main id="main-content">
<!-- page content -->
</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: var(--color-surface-primary);
color: var(--color-text-primary);
z-index: 100;
}
.skip-link:focus {
top: 0;
}
DROPDOWN_MENU¶
ARIA: - button with aria-expanded="true/false" and aria-controls="menu-id" - role="menu" on the dropdown container - role="menuitem" on each option - aria-haspopup="true" on trigger button
KEYBOARD: - Enter/Space — toggle menu - Down Arrow — open menu, move to first item - Up/Down Arrow — navigate items - Escape — close menu, return focus to trigger - Home — first item - End — last item - Type character — jump to item starting with that character
PATTERN:
<button aria-expanded="false"
aria-controls="user-menu"
aria-haspopup="true">
Account
</button>
<ul id="user-menu" role="menu" hidden>
<li role="menuitem"><a href="/profile">Profile</a></li>
<li role="menuitem"><a href="/settings">Settings</a></li>
<li role="separator"></li>
<li role="menuitem"><button>Sign out</button></li>
</ul>
MOBILE_NAVIGATION¶
RULE: hamburger button must have aria-label="Open menu" or similar RULE: aria-expanded must reflect menu state RULE: focus must be managed — open menu → focus first item, close → focus trigger
PATTERN:TABS¶
ARIA_REQUIREMENTS¶
STRUCTURE: - role="tablist" on the tab container - role="tab" on each tab button - role="tabpanel" on each content panel - aria-selected="true" on active tab, "false" on others - aria-controls on each tab pointing to its panel - aria-labelledby on each panel pointing to its tab
PATTERN:
<div role="tablist" aria-label="Project settings">
<button role="tab" id="tab-general"
aria-selected="true"
aria-controls="panel-general">
General
</button>
<button role="tab" id="tab-members"
aria-selected="false"
aria-controls="panel-members"
tabindex="-1">
Members
</button>
<button role="tab" id="tab-billing"
aria-selected="false"
aria-controls="panel-billing"
tabindex="-1">
Billing
</button>
</div>
<div role="tabpanel" id="panel-general"
aria-labelledby="tab-general">
<!-- General settings content -->
</div>
<div role="tabpanel" id="panel-members"
aria-labelledby="tab-members"
hidden>
<!-- Members content -->
</div>
<div role="tabpanel" id="panel-billing"
aria-labelledby="tab-billing"
hidden>
<!-- Billing content -->
</div>
KEYBOARD: - Tab — enter tablist, focus active tab; Tab again → move into panel content - Left/Right Arrow — move between tabs (horizontal tablist) - Up/Down Arrow — move between tabs (vertical tablist) - Home — first tab - End — last tab - Space/Enter — activate tab (if activation is manual)
FOCUS_PATTERN: roving tabindex — only the active tab has tabindex="0", others have tabindex="-1"
TESTING: Tab into tabs → verify only active tab receives focus TESTING: Arrow keys → verify tab switches TESTING: Tab from active tab → verify focus moves to panel content TESTING: screen reader announces "tab, 1 of 3, selected"
PATTERN:ACCORDION¶
ARIA_REQUIREMENTS¶
STRUCTURE: - heading element (h3, h4, etc.) wrapping each trigger button - aria-expanded="true/false" on each trigger button - aria-controls pointing to the content panel - content panel with role="region" and aria-labelledby pointing to trigger
PATTERN:
<div class="accordion">
<h3>
<button aria-expanded="true"
aria-controls="section1-content"
id="section1-trigger">
Shipping information
</button>
</h3>
<div id="section1-content" role="region"
aria-labelledby="section1-trigger">
<p>We ship within 2-5 business days...</p>
</div>
<h3>
<button aria-expanded="false"
aria-controls="section2-content"
id="section2-trigger">
Return policy
</button>
</h3>
<div id="section2-content" role="region"
aria-labelledby="section2-trigger"
hidden>
<p>Returns accepted within 30 days...</p>
</div>
</div>
KEYBOARD: - Enter/Space — toggle section - Tab — move between headers and focusable content - Down Arrow (optional) — next header - Up Arrow (optional) — previous header - Home (optional) — first header - End (optional) — last header
RULE: allow multiple sections to be open simultaneously (unless explicitly single-expand) RULE: heading level must be appropriate for page hierarchy (not always h3)
PATTERN:DATA_TABLES¶
BASIC_TABLE¶
RULE: use native
| , | RULE: | for column headers, | for row headers
RULE: add PATTERN: SORTABLE_TABLE¶RULE: use aria-sort on sortable column headers VALUES: aria-sort="ascending", "descending", "none" RULE: sort button inside th — not replacing th RESPONSIVE_TABLE¶APPROACH_1: horizontal scroll with tabindex="0" and aria-label on scroll container APPROACH_2: stack cells vertically on mobile with data-label attributes APPROACH_3: hide non-essential columns with column toggle PATTERN — SCROLLABLE: PATTERN:CAROUSEL / SLIDESHOW¶ARIA_REQUIREMENTS¶STRUCTURE: - role="region" with aria-roledescription="carousel" and aria-label - each slide: role="group" with aria-roledescription="slide" and aria-label="N of M" - previous/next buttons with clear labels - pause button if auto-rotating PATTERN: KEYBOARD: - Left/Right Arrow or Previous/Next buttons — navigate slides - Enter/Space on pause — toggle auto-rotation - Tab — reach controls, then slide content RULES: - auto-rotation MUST have pause control (WCAG 2.2.2) - auto-rotation should pause on hover and focus - slide transition must not flash more than 3 times per second - aria-live="polite" on the slide container announces changes - if carousel is decorative, consider removing it entirely PATTERN:TOAST_NOTIFICATIONS¶ARIA_REQUIREMENTS¶RULE: use role="status" for informational toasts (added to cart, saved) RULE: use role="alert" for error/warning toasts (action failed, session expiring) RULE: toasts must be announced by screen readers without moving focus RULE: toasts must be dismissible (WCAG 2.2.1 — timing adjustable) RULE: toasts must not cover interactive content (WCAG 2.4.11) PATTERN: IMPLEMENTATION_RULES: - render toast container in DOM on page load (empty) - inject toast content dynamically — aria-live picks it up - auto-dismiss after 5+ seconds for info, NO auto-dismiss for errors - provide action to undo if applicable - stack toasts if multiple appear (don't replace) KEYBOARD: - toast should NOT steal focus from current task - dismiss button reachable via Tab - Escape could dismiss latest toast (optional) PATTERN:DATE_PICKER¶APPROACH¶PREFERRED: native — best screen reader support, keyboard built-in FALLBACK: text input with format hint if native not suitable CUSTOM: only if client requires specific design — follow WAI-ARIA dialog date picker pattern PATTERN — NATIVE: PATTERN — TEXT_FALLBACK: CUSTOM_DATE_PICKER_REQUIREMENTS (if unavoidable): - dialog pattern with role="dialog" - grid pattern for calendar with role="grid" - arrow keys navigate days, PageUp/Down navigate months - Enter selects date and closes dialog - Escape closes dialog without selecting - focus returns to trigger button on close - always provide text input as alternative to calendar widget COMPONENT_PATTERNS:AGENT_INSTRUCTIONS¶FOR floris, floor: - follow these patterns as baseline for all component implementations - if shadcn/ui component matches, verify it meets these ARIA requirements before using - if it does not meet requirements, wrap or extend — do not skip accessibility - custom components MUST be tested with keyboard + NVDA before PR merge FOR alexander: - reference these patterns in DESIGN.md when specifying component behavior - design focus indicators for every interactive component - specify touch target sizes (minimum 24x24 CSS pixels, recommended 44x44) FOR antje: - use the keyboard interaction tables to verify correct behavior - test screen reader announcements match expected pattern - verify ARIA attributes are correct using browser accessibility tree FOR julian: - include component-level conformance notes in audit reports - flag custom components that deviate from APG patterns READ_ALSO: domains/accessibility/index.md, domains/accessibility/wcag-2-2.md, domains/accessibility/testing-methodology.md, domains/accessibility/pitfalls.md |
|---|