patterns.md

Accessible Patterns

Complex UI patterns with accessibility built in. For simple elements, prefer semantic HTML without these patterns.

Contents

<button onclick="openModal()" aria-haspopup="dialog">
  Open settings
</button>

<div 
  id="modal" 
  role="dialog" 
  aria-modal="true" 
  aria-labelledby="modal-title"
  hidden
>
  <h2 id="modal-title">Settings</h2>
  <div><!-- modal content --></div>
  <button onclick="closeModal()">Close</button>
</div>

Requirements:

  1. role="dialog" and aria-modal="true"
  2. Label via aria-labelledby pointing to heading
  3. Focus moves into modal on open (to first focusable element or the dialog itself)
  4. Focus trapped inside modal while open
  5. Escape key closes modal
  6. Focus returns to trigger button on close
  7. Background content hidden from screen readers (via aria-modal or inert)

Focus trap pattern (JS):

function trapFocus(modal) {
  const focusable = modal.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  modal.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });
}
<div class="menu-container">
  <button 
    id="menu-button"
    aria-expanded="false" 
    aria-haspopup="menu"
    aria-controls="menu"
  >
    Options
  </button>
  
  <ul id="menu" role="menu" aria-labelledby="menu-button" hidden>
    <li role="menuitem" tabindex="-1">Edit</li>
    <li role="menuitem" tabindex="-1">Duplicate</li>
    <li role="menuitem" tabindex="-1">Delete</li>
  </ul>
</div>

Requirements:

  1. aria-expanded toggles on button
  2. aria-haspopup="menu" on trigger
  3. role="menu" on container, role="menuitem" on items
  4. Arrow keys navigate items (not Tab)
  5. Enter/Space activates item
  6. Escape closes and returns focus to button
  7. Home/End jump to first/last item

Keyboard handling:

menu.addEventListener('keydown', (e) => {
  const items = [...menu.querySelectorAll('[role="menuitem"]')];
  const current = items.indexOf(document.activeElement);
  
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      items[(current + 1) % items.length].focus();
      break;
    case 'ArrowUp':
      e.preventDefault();
      items[(current - 1 + items.length) % items.length].focus();
      break;
    case 'Home':
      e.preventDefault();
      items[0].focus();
      break;
    case 'End':
      e.preventDefault();
      items[items.length - 1].focus();
      break;
    case 'Escape':
      closeMenu();
      button.focus();
      break;
  }
});

Tabs

<div class="tabs">
  <div role="tablist" aria-label="Account settings">
    <button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">
      Profile
    </button>
    <button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">
      Security
    </button>
    <button role="tab" aria-selected="false" aria-controls="panel-3" id="tab-3" tabindex="-1">
      Notifications
    </button>
  </div>
  
  <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    <!-- Profile content -->
  </div>
  <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    <!-- Security content -->
  </div>
  <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
    <!-- Notifications content -->
  </div>
</div>

Requirements:

  1. role="tablist" container with role="tab" buttons
  2. aria-selected="true" on active tab
  3. tabindex="-1" on inactive tabs (arrow key navigation, not Tab)
  4. role="tabpanel" for content, linked via aria-controls/aria-labelledby
  5. Left/Right arrows move between tabs
  6. Home/End jump to first/last tab

Accordions

<div class="accordion">
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="section1"
      id="accordion1"
    >
      Section 1
    </button>
  </h3>
  <div id="section1" role="region" aria-labelledby="accordion1" hidden>
    <p>Content for section 1</p>
  </div>
  
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="section2"
      id="accordion2"
    >
      Section 2
    </button>
  </h3>
  <div id="section2" role="region" aria-labelledby="accordion2" hidden>
    <p>Content for section 2</p>
  </div>
</div>

Requirements:

  1. Headings wrap buttons (preserves heading hierarchy for screen readers)
  2. aria-expanded indicates open/closed state
  3. aria-controls links button to panel
  4. Panel has role="region" and aria-labelledby
  5. Space/Enter toggles the panel

Toast notifications

<!-- Container for all toasts -->
<div 
  role="status" 
  aria-live="polite" 
  aria-atomic="true" 
  class="toast-container"
>
  <!-- Toasts injected here -->
</div>

<!-- For urgent/error messages -->
<div 
  role="alert" 
  aria-live="assertive"
  class="alert-container"
>
  <!-- Alerts injected here -->
</div>

Usage:

  • aria-live="polite": Announced after current speech (success messages, updates)
  • aria-live="assertive": Interrupts immediately (errors, critical alerts)
  • role="status": Implicit aria-live="polite"
  • role="alert": Implicit aria-live="assertive"

Requirements:

  1. Live region must exist in DOM before content is added
  2. Don't use for navigation or frequent updates (too noisy)
  3. Error toasts should be assertive, success should be polite
  4. Provide dismiss button if toast persists
  5. Don't auto-dismiss error messages

Data tables

<table>
  <caption>Quarterly sales by region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
      <th scope="col">Q3</th>
      <th scope="col">Q4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>$1.2M</td>
      <td>$1.4M</td>
      <td>$1.1M</td>
      <td>$1.6M</td>
    </tr>
    <tr>
      <th scope="row">South</th>
      <td>$0.9M</td>
      <td>$1.0M</td>
      <td>$1.2M</td>
      <td>$1.3M</td>
    </tr>
  </tbody>
</table>

Requirements:

  1. <caption> describes the table's purpose
  2. <th> for headers with scope="col" or scope="row"
  3. Don't use tables for layout
  4. For complex tables with multi-level headers, use id/headers attributes
  5. Sortable columns need aria-sort="ascending|descending|none"
<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>
.skip-link {
  position: absolute;
  left: -9999px;
  z-index: 999;
  padding: 1em;
  background: #000;
  color: #fff;
}

.skip-link:focus {
  left: 50%;
  transform: translateX(-50%);
  top: 0;
}

Requirements:

  1. First focusable element on page
  2. Links to <main> or primary content area
  3. Visible on focus (hidden until needed)
  4. Target element needs tabindex="-1" for programmatic focus

Forms with validation

<form novalidate>
  <div class="field">
    <label for="email">Email address <span aria-hidden="true">*</span></label>
    <input 
      type="email" 
      id="email" 
      name="email"
      required
      aria-required="true"
      aria-invalid="false"
      aria-describedby="email-error"
    >
    <span id="email-error" class="error" hidden></span>
  </div>
  
  <button type="submit">Submit</button>
</form>

On validation error:

function showError(input, message) {
  const errorEl = document.getElementById(input.id + '-error');
  input.setAttribute('aria-invalid', 'true');
  errorEl.textContent = message;
  errorEl.hidden = false;
  input.focus();
}

Requirements:

  1. <label> for every input (visible, not just aria-label)
  2. Required fields: required + aria-required="true" + visual indicator
  3. Errors linked via aria-describedby
  4. aria-invalid="true" on invalid fields
  5. Move focus to first error on submit
  6. Error messages must be specific ("Email must include @" not "Invalid")
  7. Don't clear fields on error (let users fix without retyping)