DOMAIN:ACCESSIBILITY:PITFALLS¶
OWNER: julian (compliance), alexander (design) ALSO_USED_BY: floris, floor, antje UPDATED: 2026-03-26 SCOPE: common accessibility mistakes and how to avoid them
PITFALLS:OVERVIEW¶
CONTEXT: the WebAIM Million 2025 report found pages with ARIA present have over twice as many errors (57) as pages without ARIA (27) CONTEXT: ARIA usage increased ~20% since 2024, yet error rates remain high LESSON: more ARIA does not mean more accessible — most ARIA mistakes make things worse
PITFALL:ARIA_OVERUSE¶
THE_PROBLEM¶
SYMPTOM: developers add ARIA to everything "just in case" RESULT: screen readers get conflicting or redundant information RESULT: assistive technology behavior becomes unpredictable ROOT_CAUSE: misunderstanding that ARIA improves accessibility — it only helps when native HTML is insufficient
EXAMPLES¶
BAD: <div role="button" tabindex="0" onclick="submit()">Submit</div>
GOOD: <button type="submit">Submit</button>
BAD: <a href="/home" role="link">Home</a> — link already has implicit role
GOOD: <a href="/home">Home</a>
BAD: <nav role="navigation" aria-label="Navigation"> — redundant role
GOOD: <nav aria-label="Main navigation">
BAD: <h2 role="heading" aria-level="2">Title</h2> — already a heading
GOOD: <h2>Title</h2>
RULE¶
FIRST: can a native HTML element do this? If yes, use it. SECOND: if no native element exists, use ARIA. THIRD: never add ARIA that duplicates what the browser already provides. REFERENCE: W3C "first rule of ARIA" — w3.org/TR/using-aria/#rule1
PITFALL:FOCUS_TRAP_MISTAKES¶
PROBLEM_1 — NO_FOCUS_TRAP_IN_MODAL¶
SYMPTOM: user opens modal, tabs out of it into background content IMPACT: screen reader user has no idea they left the modal FIX: implement focus trap — Tab and Shift+Tab must cycle within modal only SEE: component-patterns.md → PATTERN:MODAL_DIALOG
PROBLEM_2 — UNINTENTIONAL_KEYBOARD_TRAP¶
SYMPTOM: user tabs into a component and cannot Tab out IMPACT: keyboard user is stuck — must refresh page COMMON_IN: custom rich text editors, embedded maps, iframe content, video players FIX: always test that Tab can exit every component FIX: for embeds/iframes — ensure Escape key returns focus to parent page WCAG: 2.1.2 No Keyboard Trap — Level A
PROBLEM_3 — FOCUS_NOT_RETURNED_AFTER_MODAL¶
SYMPTOM: modal closes, focus goes to top of page or document body IMPACT: keyboard user loses their place — must tab through entire page again FIX: store reference to trigger element, restore focus on close
// Store trigger before opening
const trigger = document.activeElement as HTMLElement;
openModal();
// On close
closeModal();
trigger.focus();
PITFALL:SKIP_LINKS_FORGOTTEN¶
THE_PROBLEM¶
SYMPTOM: no skip navigation link at top of page IMPACT: keyboard users must tab through entire header/nav on every page load IMPACT: particularly painful on pages with large navigation menus WCAG: 2.4.1 Bypass Blocks — Level A
THE_FIX¶
RULE: every page must have a skip link as the FIRST focusable element RULE: skip link targets the main content area RULE: skip link must be visible when focused (not permanently hidden)
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header><!-- navigation --></header>
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
</body>
COMMON_MISTAKE: skip link is present but display:none (never visible, never focusable) COMMON_MISTAKE: skip link target does not exist (href="#main" but no id="main") COMMON_MISTAKE: skip link target is not focusable (missing tabindex="-1" on non-interactive element)
PITFALL:DYNAMIC_CONTENT_NOT_ANNOUNCED¶
THE_PROBLEM¶
SYMPTOM: content updates on screen (cart count, notifications, loading states) but screen reader says nothing IMPACT: screen reader user has no idea something changed ROOT_CAUSE: missing aria-live regions
COMMON_SCENARIOS¶
SCENARIO: item added to cart — counter updates visually but not announced
FIX: <span aria-live="polite">3 items in cart</span>
SCENARIO: form submitted — success message appears but not announced
FIX: <div role="status">Form submitted successfully</div>
SCENARIO: loading spinner appears and disappears
FIX: <div aria-live="polite">{isLoading ? 'Loading...' : 'Content loaded'}</div>
SCENARIO: SPA route change — new page content renders but nothing announced FIX: move focus to main content or announce page title via aria-live
SCENARIO: infinite scroll — new content appears at bottom
FIX: <div aria-live="polite">25 more results loaded</div>
RULES¶
RULE: use aria-live="polite" for non-urgent updates (most cases) RULE: use aria-live="assertive" ONLY for critical alerts (errors, session expiry) RULE: never use aria-live="assertive" for routine updates — it interrupts the user RULE: aria-live region must exist in DOM before content changes (inject content into it, not the region itself) RULE: do not wrap entire page sections in aria-live — only the changing content
OVER_ANNOUNCING¶
PITFALL: too many aria-live updates create noise EXAMPLE: search-as-you-type updating "3 results... 5 results... 12 results..." on every keystroke FIX: debounce announcements — announce once after user stops typing (300ms) FIX: use aria-live="polite" so announcements wait for speech to finish
PITFALL:COLOR_ONLY_INDICATORS¶
THE_PROBLEM¶
SYMPTOM: information conveyed by color alone (red = error, green = success, orange = warning) IMPACT: color-blind users (~8% of males) cannot distinguish states WCAG: 1.4.1 Use of Color — Level A
EXAMPLES¶
BAD: form field border turns red on error (no text, no icon) GOOD: red border + error icon + error text message
BAD: chart with 5 colored lines, no patterns or labels GOOD: chart with colored lines + different dash patterns + labels
BAD: link text in blue with no underline (indistinguishable from body text for color-blind users) GOOD: link text underlined OR bolded OR with icon
BAD: green/red status badges with no text GOOD: status badges with color + text ("Active", "Inactive") + icon
RULE¶
RULE: every color-coded indicator must have a secondary visual cue (icon, text, pattern, border) RULE: test all designs in grayscale to verify information is still conveyed RULE: never describe actions by color ("click the green button")
PITFALL:AUTO_PLAYING_MEDIA¶
THE_PROBLEM¶
SYMPTOM: audio or video plays automatically on page load IMPACT: screen reader users cannot hear their assistive technology IMPACT: users in quiet environments are disrupted IMPACT: users with cognitive disabilities are distracted WCAG: 1.4.2 Audio Control — Level A
RULES¶
RULE: never auto-play audio RULE: if video auto-plays, it MUST be muted by default RULE: provide visible pause/stop button RULE: if any media plays longer than 3 seconds, provide mechanism to pause, stop, or adjust volume RULE: background video (decorative) must be pausable and muted
IMPLEMENTATION¶
<!-- Video with auto-play MUST be muted -->
<video autoplay muted loop>
<source src="hero.mp4" type="video/mp4">
</video>
<button aria-label="Pause background video">Pause</button>
PREFERRED: no auto-play at all — let user initiate playback
PITFALL:CUSTOM_COMPONENT_KEYBOARD_TRAPS¶
THE_PROBLEM¶
SYMPTOM: custom components (date pickers, rich text editors, WYSIWYG, color pickers, map embeds) capture keyboard input and do not release it IMPACT: keyboard user cannot leave the component WCAG: 2.1.2 No Keyboard Trap — Level A
HIGH_RISK_COMPONENTS¶
COMPONENT: rich text editor (TipTap, Quill, ProseMirror) RISK: Tab key inserts tab character instead of moving focus FIX: use Escape to exit editor, then Tab moves focus forward
COMPONENT: embedded map (Google Maps, Mapbox) RISK: arrow keys scroll map instead of page FIX: require click/tap to activate map, Escape to deactivate
COMPONENT: code editor (Monaco, CodeMirror) RISK: Tab key indents code instead of moving focus FIX: Escape exits edit mode, then Tab moves focus
COMPONENT: iframe content (third-party widgets) RISK: focus enters iframe and cannot exit FIX: ensure iframe content supports Escape to return to parent
TESTING_PROTOCOL¶
TEST: Tab into the component TEST: attempt to Tab out — does focus leave the component? TEST: if Tab is captured, does Escape release focus? TEST: if neither works, this is a keyboard trap — MUST FIX
PITFALL:PDF_ACCESSIBILITY¶
THE_PROBLEM¶
SYMPTOM: application generates or exports PDFs that are inaccessible IMPACT: screen reader users cannot read the document IMPACT: EAA EN 301 549 Chapter 10 covers non-web documents including PDF
RULES¶
RULE: generated PDFs must be tagged (structured, not just visual) RULE: headings, lists, tables must have proper PDF structure tags RULE: images in PDFs must have alt text RULE: reading order must be correct RULE: language must be set in document properties
TESTING¶
TEST: open PDF in Adobe Acrobat → Accessibility Checker TEST: open PDF with NVDA — can it read content in correct order? TEST: verify form fields in PDF are accessible
GE_APPROACH¶
IF client project generates PDFs THEN: - use a PDF library that supports tagged PDF output (pdf-lib, puppeteer with tags) - add accessibility check to PDF generation pipeline - include PDF accessibility in conformance testing
PITFALL:ANIMATION_AND_MOTION¶
THE_PROBLEM¶
SYMPTOM: animations play regardless of user preference IMPACT: users with vestibular disorders experience nausea, dizziness, or seizures WCAG: 2.3.1 Three Flashes — Level A WCAG: 2.3.3 Animation from Interactions — Level AAA (but respect user preference)
RULES¶
RULE: respect prefers-reduced-motion media query RULE: nothing flashes more than 3 times per second RULE: parallax scrolling effects must be disabled when reduced motion is preferred RULE: page transition animations must be disabled when reduced motion is preferred
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
RULE: this CSS must be in every project's global styles
PITFALL:MISSING_LANGUAGE_ATTRIBUTE¶
SYMPTOM: <html> element has no lang attribute
IMPACT: screen reader uses wrong pronunciation rules (Dutch content read with English pronunciation)
FIX: <html lang="nl"> for Dutch, <html lang="en"> for English
FIX: <span lang="en">click here</span> for inline language switches
WCAG: 3.1.1 Language of Page — Level A
GE_RULE: every page template must include lang attribute GE_RULE: if content is multilingual, mark language switches with lang attribute on the element
PITFALL:IMAGES_WITHOUT_ALT¶
SYMPTOM: <img> without alt attribute (NOT the same as empty alt)
IMPACT: screen reader reads the filename, which is meaningless
FIX_INFORMATIVE: <img alt="Team meeting in Amsterdam office" src="meeting.jpg">
FIX_DECORATIVE: <img alt="" src="decorative-border.svg">
FIX_COMPLEX: <img alt="Revenue chart" src="chart.png" aria-describedby="chart-desc">
CRITICAL: missing alt attribute and empty alt="" are DIFFERENT - missing alt → screen reader reads filename (bad) - alt="" → screen reader skips image entirely (correct for decorative)
GE_RULE: every must have an alt attribute — enforce in linting (eslint-plugin-jsx-a11y)
PITFALLS:AGENT_INSTRUCTIONS¶
FOR floris, floor: - review this list before EVERY PR involving UI components - add eslint-plugin-jsx-a11y to project linting config - ensure prefers-reduced-motion CSS is in global styles - test keyboard navigation for every interactive component you build
FOR alexander: - design states for all components (error, loading, empty, success) — not just happy path - specify non-color indicators for all status states - include reduced-motion considerations in DESIGN.md
FOR antje: - use this list as a negative testing checklist - specifically test: keyboard traps, missing announcements, color-only indicators - test with prefers-reduced-motion enabled
FOR julian: - include pitfall coverage in conformance reports - track recurring pitfalls across projects — feed back to team as training
READ_ALSO: domains/accessibility/index.md, domains/accessibility/component-patterns.md, domains/accessibility/testing-methodology.md