Skip to content

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();

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