Interactive Components and Widgets

1. Button States and Properties

Button Type Element Required Attributes Use Case
Submit Button <button type="submit"> None (native behavior) Form submission
Reset Button <button type="reset"> None Clear form fields
Regular Button <button type="button"> None JavaScript actions
Link Button <a href="..." role="button"> role="button" (if styled as button) Navigation that looks like button
Custom Button <div role="button"> role="button", tabindex="0" Only when native button impossible
Toggle Button <button aria-pressed> aria-pressed="true/false" On/off state (bold, italic)
Menu Button <button aria-haspopup> aria-haspopup="menu", aria-expanded Opens dropdown menu

Example: Button patterns and states

<!-- Basic buttons -->
<button type="button" onclick="save()">Save</button>
<button type="submit">Submit Form</button>
<button type="reset">Clear</button>

<!-- Disabled button -->
<button disabled>Unavailable Action</button>
<button aria-disabled="true" onclick="showWarning()">
  Disabled but still focusable
</button>

<!-- Toggle button (pressed state) -->
<button 
  type="button"
  aria-pressed="false"
  onclick="this.setAttribute('aria-pressed', 
    this.getAttribute('aria-pressed') === 'false' ? 'true' : 'false')">
  <span aria-hidden="true">★</span> Favorite
</button>

<!-- Menu button -->
<button 
  type="button"
  aria-haspopup="menu"
  aria-expanded="false"
  aria-controls="menu-dropdown"
  id="menu-button">
  Options ▼
</button>
<ul id="menu-dropdown" role="menu" hidden>
  <li role="menuitem">Edit</li>
  <li role="menuitem">Delete</li>
</ul>

<!-- Button with loading state -->
<button type="submit" id="submit-btn">
  <span class="btn-content">Save Changes</span>
  <span class="btn-loading" hidden>
    <span class="spinner" aria-hidden="true"></span>
    Saving...
  </span>
</button>

<!-- Icon-only button (requires label) -->
<button type="button" aria-label="Close dialog">
  <svg aria-hidden="true">...close icon...</svg>
</button>

<!-- Button with description -->
<button 
  type="button"
  aria-describedby="delete-help">
  Delete Account
</button>
<span id="delete-help" class="help-text">
  This action cannot be undone
</span>
ARIA Attribute Values Purpose Example Use
aria-label String Accessible name for icon-only buttons Close, Menu, Search buttons
aria-labelledby ID reference Label button with another element's text Button labeled by heading
aria-describedby ID reference(s) Additional context or help text Destructive actions with warnings
aria-pressed true/false/mixed Toggle button state Bold, Italic, Favorite toggles
aria-expanded true/false Expandable content state Dropdown, accordion buttons
aria-haspopup menu/dialog/grid/listbox/tree Indicates popup type Menu buttons, comboboxes
aria-controls ID reference Element controlled by button Tab panels, dropdowns
aria-disabled true/false Disabled but focusable (vs disabled attr) Disabled with tooltip explanation
aria-busy true/false Loading/processing state Submit buttons during API call
Warning: Don't use <div> or <span> for buttons unless absolutely necessary. Native <button> elements work better with assistive tech and handle keyboard interaction automatically.
disabled vs aria-disabled Behavior When to Use
disabled attribute Not focusable, not in tab order, grayed out Standard disabled state - user can't interact
aria-disabled="true" Still focusable, in tab order, can show tooltip Need to explain why disabled (tooltip on focus)

2. Modal and Dialog Implementation

Dialog Type Role Behavior Use Case
Modal Dialog role="dialog" + aria-modal="true" Blocks interaction with background, focus trap Confirmations, forms requiring attention
Alert Dialog role="alertdialog" Interrupts workflow, requires response Critical warnings, errors
Non-Modal Dialog role="dialog" without aria-modal Allows background interaction Inspectors, tool palettes (rare)

Example: Complete modal dialog implementation

<!-- Trigger button -->
<button type="button" onclick="openDialog()">Open Dialog</button>

<!-- Modal dialog -->
<div 
  id="my-dialog"
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc"
  class="modal"
  hidden>
  
  <!-- Backdrop -->
  <div class="modal-backdrop" onclick="closeDialog()"></div>
  
  <!-- Dialog content -->
  <div class="modal-content">
    <div class="modal-header">
      <h2 id="dialog-title">Confirm Action</h2>
      <button 
        type="button" 
        aria-label="Close dialog"
        onclick="closeDialog()"
        class="close-btn">
        ×
      </button>
    </div>
    
    <div class="modal-body">
      <p id="dialog-desc">
        Are you sure you want to delete this item? This action cannot be undone.
      </p>
    </div>
    
    <div class="modal-footer">
      <button type="button" onclick="closeDialog()">Cancel</button>
      <button type="button" onclick="confirmAction()" class="btn-danger">
        Delete
      </button>
    </div>
  </div>
</div>

<style>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal[hidden] {
  display: none;
}

.modal-backdrop {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
}

.modal-content {
  position: relative;
  background: white;
  max-width: 500px;
  width: 90%;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  z-index: 1;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  border-bottom: 1px solid #e0e0e0;
}

.close-btn {
  background: none;
  border: none;
  font-size: 28px;
  cursor: pointer;
  padding: 0;
  width: 32px;
  height: 32px;
}

.modal-body {
  padding: 20px;
}

.modal-footer {
  padding: 16px 20px;
  border-top: 1px solid #e0e0e0;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
</style>

<script>
let previousFocus = null;
const dialog = document.getElementById('my-dialog');

function openDialog() {
  // Store previous focus
  previousFocus = document.activeElement;
  
  // Make background inert
  document.body.style.overflow = 'hidden';
  
  // Show dialog
  dialog.hidden = false;
  
  // Focus first focusable element (close button or first action)
  const firstFocusable = dialog.querySelector('button');
  firstFocusable.focus();
  
  // Setup focus trap
  setupFocusTrap();
  
  // Listen for Escape key
  document.addEventListener('keydown', handleEscape);
}

function closeDialog() {
  // Hide dialog
  dialog.hidden = true;
  
  // Restore body scroll
  document.body.style.overflow = '';
  
  // Remove escape listener
  document.removeEventListener('keydown', handleEscape);
  
  // Restore focus
  if (previousFocus) {
    previousFocus.focus();
  }
}

function handleEscape(e) {
  if (e.key === 'Escape') {
    closeDialog();
  }
}

function setupFocusTrap() {
  const focusableElements = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  
  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];
  
  dialog.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;
    
    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  });
}

function confirmAction() {
  console.log('Action confirmed');
  closeDialog();
}
</script>
Modal Requirement Implementation WCAG Criteria
Focus Management Focus first element on open, restore on close 2.4.3 Focus Order
Focus Trap Tab cycles only within dialog 2.1.2 No Keyboard Trap
Escape to Close Escape key dismisses dialog 2.1.1 Keyboard
Accessible Name aria-labelledby references title 4.1.2 Name, Role, Value
Description aria-describedby for main content 4.1.2 Name, Role, Value
Background Interaction aria-modal="true" or inert on background 2.4.3 Focus Order
Initial Focus Focus close button, first action, or first field Best practice
Note: Use <dialog> HTML element when browser support allows. It handles focus trap and backdrop automatically. Call dialog.showModal() to open.

3. Dropdown and Combobox Patterns

Pattern Role Keyboard Use Case
Simple Dropdown role="menu" + role="menuitem" ↓↑ navigate, Enter select, Esc close Action menus (Edit, Delete, Share)
Select-Only Combobox role="combobox" + role="listbox" ↓↑ navigate, Enter select, type-ahead Country selector, category picker
Editable Combobox role="combobox" + input + role="listbox" Type to filter, ↓↑ navigate, Enter select Autocomplete search, tag input
Native Select <select> element Native browser behavior Preferred when possible - best support

Example: Accessible dropdown menu

<!-- Dropdown menu button -->
<div class="dropdown">
  <button 
    type="button"
    id="menu-button"
    aria-haspopup="menu"
    aria-expanded="false"
    aria-controls="menu-list"
    onclick="toggleMenu()">
    Actions ▼
  </button>
  
  <ul 
    id="menu-list"
    role="menu"
    aria-labelledby="menu-button"
    hidden>
    <li role="none">
      <button role="menuitem" onclick="edit()">Edit</button>
    </li>
    <li role="none">
      <button role="menuitem" onclick="duplicate()">Duplicate</button>
    </li>
    <li role="separator"></li>
    <li role="none">
      <button role="menuitem" onclick="deleteItem()">Delete</button>
    </li>
  </ul>
</div>

<script>
const menuButton = document.getElementById('menu-button');
const menuList = document.getElementById('menu-list');
const menuItems = menuList.querySelectorAll('[role="menuitem"]');
let currentIndex = -1;

function toggleMenu() {
  const isOpen = menuButton.getAttribute('aria-expanded') === 'true';
  
  if (isOpen) {
    closeMenu();
  } else {
    openMenu();
  }
}

function openMenu() {
  menuButton.setAttribute('aria-expanded', 'true');
  menuList.hidden = false;
  currentIndex = 0;
  menuItems[0].focus();
  
  // Add event listeners
  document.addEventListener('click', handleOutsideClick);
  menuList.addEventListener('keydown', handleMenuKeydown);
}

function closeMenu() {
  menuButton.setAttribute('aria-expanded', 'false');
  menuList.hidden = true;
  menuButton.focus();
  currentIndex = -1;
  
  // Remove event listeners
  document.removeEventListener('click', handleOutsideClick);
  menuList.removeEventListener('keydown', handleMenuKeydown);
}

function handleMenuKeydown(e) {
  switch(e.key) {
    case 'ArrowDown':
      e.preventDefault();
      currentIndex = (currentIndex + 1) % menuItems.length;
      menuItems[currentIndex].focus();
      break;
    case 'ArrowUp':
      e.preventDefault();
      currentIndex = (currentIndex - 1 + menuItems.length) % menuItems.length;
      menuItems[currentIndex].focus();
      break;
    case 'Home':
      e.preventDefault();
      currentIndex = 0;
      menuItems[0].focus();
      break;
    case 'End':
      e.preventDefault();
      currentIndex = menuItems.length - 1;
      menuItems[currentIndex].focus();
      break;
    case 'Escape':
      e.preventDefault();
      closeMenu();
      break;
    case 'Tab':
      e.preventDefault();
      closeMenu();
      break;
  }
}

function handleOutsideClick(e) {
  if (!menuList.contains(e.target) && e.target !== menuButton) {
    closeMenu();
  }
}
</script>

Example: Autocomplete combobox with filtering

<!-- Autocomplete combobox -->
<div class="combobox-wrapper">
  <label for="country-input">Country</label>
  <input 
    type="text"
    id="country-input"
    role="combobox"
    aria-autocomplete="list"
    aria-expanded="false"
    aria-controls="country-listbox"
    aria-activedescendant=""
    autocomplete="off"
    placeholder="Type to search...">
  
  <ul 
    id="country-listbox"
    role="listbox"
    aria-label="Countries"
    hidden>
  </ul>
</div>

<script>
const countries = [
  'United States', 'United Kingdom', 'Canada', 'Australia',
  'Germany', 'France', 'Spain', 'Italy', 'Japan', 'China'
];

const input = document.getElementById('country-input');
const listbox = document.getElementById('country-listbox');
let currentOption = -1;

input.addEventListener('input', (e) => {
  const value = e.target.value.toLowerCase();
  
  if (!value) {
    closeListbox();
    return;
  }
  
  // Filter countries
  const filtered = countries.filter(country => 
    country.toLowerCase().includes(value)
  );
  
  // Populate listbox
  listbox.innerHTML = filtered.map((country, index) => `
    <li 
      role="option" 
      id="option-${index}"
      onclick="selectOption('${country}')">
      ${country}
    </li>
  `).join('');
  
  if (filtered.length > 0) {
    openListbox();
  } else {
    closeListbox();
  }
});

input.addEventListener('keydown', (e) => {
  const options = listbox.querySelectorAll('[role="option"]');
  
  switch(e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (input.getAttribute('aria-expanded') === 'false') {
        openListbox();
      }
      currentOption = Math.min(currentOption + 1, options.length - 1);
      updateActiveDescendant(options);
      break;
    case 'ArrowUp':
      e.preventDefault();
      currentOption = Math.max(currentOption - 1, 0);
      updateActiveDescendant(options);
      break;
    case 'Enter':
      e.preventDefault();
      if (currentOption >= 0 && options[currentOption]) {
        selectOption(options[currentOption].textContent.trim());
      }
      break;
    case 'Escape':
      closeListbox();
      break;
  }
});

function updateActiveDescendant(options) {
  options.forEach((opt, idx) => {
    opt.classList.toggle('selected', idx === currentOption);
  });
  
  if (options[currentOption]) {
    input.setAttribute('aria-activedescendant', options[currentOption].id);
  }
}

function openListbox() {
  input.setAttribute('aria-expanded', 'true');
  listbox.hidden = false;
  currentOption = -1;
}

function closeListbox() {
  input.setAttribute('aria-expanded', 'false');
  input.setAttribute('aria-activedescendant', '');
  listbox.hidden = true;
  currentOption = -1;
}

function selectOption(value) {
  input.value = value;
  closeListbox();
  input.focus();
}

// Close on outside click
document.addEventListener('click', (e) => {
  if (!input.contains(e.target) && !listbox.contains(e.target)) {
    closeListbox();
  }
});
</script>
Combobox Attribute Value Purpose
role="combobox" - Identifies the input as a combobox
aria-autocomplete list/inline/both/none Indicates autocomplete behavior
aria-expanded true/false Whether listbox is visible
aria-controls ID of listbox Links input to dropdown list
aria-activedescendant ID of active option Current highlighted option (virtual focus)
aria-owns ID(s) Alternative to aria-controls (older pattern)
Warning: Combobox is one of the most complex ARIA patterns. Use native <select> or <datalist> when possible. Only implement custom combobox when absolutely necessary.

4. Tab Panel Components

Element Role Required Attributes Purpose
Tab Container role="tablist" aria-label or aria-labelledby Container for tabs
Individual Tab role="tab" aria-selected, aria-controls, tabindex Tab trigger button
Tab Panel role="tabpanel" aria-labelledby, tabindex="0" Content area for active tab

Example: Complete tab panel implementation

<div class="tabs-container">
  <!-- Tab list -->
  <div role="tablist" aria-label="Content sections">
    <button 
      role="tab" 
      aria-selected="true"
      aria-controls="panel-1"
      id="tab-1"
      tabindex="0">
      Overview
    </button>
    <button 
      role="tab" 
      aria-selected="false"
      aria-controls="panel-2"
      id="tab-2"
      tabindex="-1">
      Features
    </button>
    <button 
      role="tab" 
      aria-selected="false"
      aria-controls="panel-3"
      id="tab-3"
      tabindex="-1">
      Pricing
    </button>
  </div>
  
  <!-- Tab panels -->
  <div 
    role="tabpanel" 
    id="panel-1"
    aria-labelledby="tab-1"
    tabindex="0">
    <h3>Overview Content</h3>
    <p>This is the overview section...</p>
  </div>
  
  <div 
    role="tabpanel" 
    id="panel-2"
    aria-labelledby="tab-2"
    tabindex="0"
    hidden>
    <h3>Features Content</h3>
    <p>This is the features section...</p>
  </div>
  
  <div 
    role="tabpanel" 
    id="panel-3"
    aria-labelledby="tab-3"
    tabindex="0"
    hidden>
    <h3>Pricing Content</h3>
    <p>This is the pricing section...</p>
  </div>
</div>

<style>
.tabs-container {
  border: 1px solid #ddd;
  border-radius: 4px;
}

[role="tablist"] {
  display: flex;
  border-bottom: 2px solid #ddd;
  background: #f5f5f5;
}

[role="tab"] {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  position: relative;
  transition: all 0.2s;
}

[role="tab"]:hover {
  background: #e0e0e0;
}

[role="tab"]:focus {
  outline: 2px solid #0066cc;
  outline-offset: -2px;
  z-index: 1;
}

[role="tab"][aria-selected="true"] {
  background: white;
  border-bottom: 3px solid #0066cc;
  font-weight: 600;
  color: #0066cc;
}

[role="tabpanel"] {
  padding: 20px;
}

[role="tabpanel"]:focus {
  outline: 2px solid #0066cc;
  outline-offset: -2px;
}
</style>

<script>
class TabWidget {
  constructor(tablist) {
    this.tablist = tablist;
    this.tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
    this.panels = this.tabs.map(tab => 
      document.getElementById(tab.getAttribute('aria-controls'))
    );
    this.currentIndex = this.tabs.findIndex(
      tab => tab.getAttribute('aria-selected') === 'true'
    );
    
    this.setupEventListeners();
  }
  
  setupEventListeners() {
    this.tabs.forEach((tab, index) => {
      tab.addEventListener('click', () => this.selectTab(index));
      tab.addEventListener('keydown', (e) => this.handleKeydown(e, index));
    });
  }
  
  selectTab(index) {
    // Deselect all tabs
    this.tabs.forEach((tab, i) => {
      const isSelected = i === index;
      tab.setAttribute('aria-selected', isSelected);
      tab.setAttribute('tabindex', isSelected ? '0' : '-1');
      
      // Show/hide panels
      if (this.panels[i]) {
        this.panels[i].hidden = !isSelected;
      }
    });
    
    // Focus selected tab
    this.tabs[index].focus();
    this.currentIndex = index;
  }
  
  handleKeydown(e, currentIndex) {
    let newIndex = currentIndex;
    
    switch(e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        newIndex = (currentIndex - 1 + this.tabs.length) % this.tabs.length;
        this.selectTab(newIndex);
        break;
      case 'ArrowRight':
        e.preventDefault();
        newIndex = (currentIndex + 1) % this.tabs.length;
        this.selectTab(newIndex);
        break;
      case 'Home':
        e.preventDefault();
        this.selectTab(0);
        break;
      case 'End':
        e.preventDefault();
        this.selectTab(this.tabs.length - 1);
        break;
    }
  }
}

// Initialize all tab widgets
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
  new TabWidget(tablist);
});
</script>
Tab Pattern Best Practice Implementation Reason
Roving tabindex Selected tab: tabindex="0", others: tabindex="-1" Only one tab in tab order at a time
Arrow Key Navigation Left/Right arrows move between tabs Standard tab pattern expectation
Automatic Activation Arrow keys both focus AND activate tab Recommended (vs manual activation)
Home/End Keys Jump to first/last tab Convenience for many tabs
Panel Focusable Panels have tabindex="0" Allows keyboard users to scroll panel
aria-selected Only selected tab has aria-selected="true" Communicates state to screen readers
Note: There are two tab activation patterns: Automatic (arrow keys activate immediately - recommended) and Manual (arrow keys focus, Enter/Space activates). Automatic is more common and preferred.

5. Accordion and Disclosure Widgets

Pattern HTML Element ARIA Pattern Use Case
Native Disclosure <details> + <summary> None (built-in) Simple show/hide content (preferred)
ARIA Disclosure <button aria-expanded> aria-expanded, aria-controls Custom styled disclosure
Accordion Multiple disclosure widgets Same as disclosure, grouped FAQ sections, settings panels

Example: Native details/summary (preferred)

<!-- Native disclosure (best accessibility) -->
<details>
  <summary>What is accessibility?</summary>
  <p>
    Accessibility ensures that websites and applications can be used by 
    everyone, including people with disabilities who may use assistive 
    technologies like screen readers.
  </p>
</details>

<details open>
  <summary>Why is it important?</summary>
  <p>
    It's not just a legal requirement - it makes your content available 
    to a wider audience and improves usability for everyone.
  </p>
</details>

<style>
details {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 12px;
  margin-bottom: 8px;
}

summary {
  cursor: pointer;
  font-weight: 600;
  padding: 8px 0;
  list-style: none; /* Remove default marker */
  display: flex;
  align-items: center;
}

/* Custom marker */
summary::before {
  content: "▶";
  margin-right: 8px;
  transition: transform 0.2s;
  display: inline-block;
}

details[open] summary::before {
  transform: rotate(90deg);
}

/* Remove default marker in WebKit */
summary::-webkit-details-marker {
  display: none;
}

summary:focus {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  border-radius: 2px;
}

details p {
  margin-top: 12px;
  padding-left: 24px;
}
</style>

Example: Custom accordion with ARIA

<!-- Custom accordion -->
<div class="accordion">
  <h3>
    <button 
      type="button"
      aria-expanded="false"
      aria-controls="section1"
      id="accordion1"
      class="accordion-trigger">
      <span class="accordion-title">Personal Information</span>
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
  </h3>
  <div 
    id="section1"
    role="region"
    aria-labelledby="accordion1"
    class="accordion-panel"
    hidden>
    <p>Content for personal information section...</p>
  </div>
  
  <h3>
    <button 
      type="button"
      aria-expanded="false"
      aria-controls="section2"
      id="accordion2"
      class="accordion-trigger">
      <span class="accordion-title">Account Settings</span>
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
  </h3>
  <div 
    id="section2"
    role="region"
    aria-labelledby="accordion2"
    class="accordion-panel"
    hidden>
    <p>Content for account settings section...</p>
  </div>
  
  <h3>
    <button 
      type="button"
      aria-expanded="false"
      aria-controls="section3"
      id="accordion3"
      class="accordion-trigger">
      <span class="accordion-title">Privacy Options</span>
      <span class="accordion-icon" aria-hidden="true">+</span>
    </button>
  </h3>
  <div 
    id="section3"
    role="region"
    aria-labelledby="accordion3"
    class="accordion-panel"
    hidden>
    <p>Content for privacy options section...</p>
  </div>
</div>

<style>
.accordion {
  border: 1px solid #ddd;
  border-radius: 4px;
}

.accordion h3 {
  margin: 0;
  border-bottom: 1px solid #ddd;
}

.accordion h3:last-of-type {
  border-bottom: none;
}

.accordion-trigger {
  width: 100%;
  padding: 16px;
  border: none;
  background: white;
  text-align: left;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 16px;
  transition: background 0.2s;
}

.accordion-trigger:hover {
  background: #f5f5f5;
}

.accordion-trigger:focus {
  outline: 2px solid #0066cc;
  outline-offset: -2px;
  z-index: 1;
}

.accordion-trigger[aria-expanded="true"] {
  background: #f0f8ff;
}

.accordion-icon {
  font-size: 24px;
  font-weight: bold;
  transition: transform 0.2s;
}

.accordion-trigger[aria-expanded="true"] .accordion-icon {
  transform: rotate(45deg);
}

.accordion-panel {
  padding: 16px;
  border-top: 1px solid #ddd;
}

.accordion-panel[hidden] {
  display: none;
}
</style>

<script>
class Accordion {
  constructor(element) {
    this.accordion = element;
    this.triggers = Array.from(element.querySelectorAll('.accordion-trigger'));
    this.allowMultiple = element.hasAttribute('data-allow-multiple');
    
    this.triggers.forEach((trigger, index) => {
      trigger.addEventListener('click', () => this.toggle(index));
    });
  }
  
  toggle(index) {
    const trigger = this.triggers[index];
    const panel = document.getElementById(trigger.getAttribute('aria-controls'));
    const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
    
    // Close other panels if not allowing multiple
    if (!this.allowMultiple && !isExpanded) {
      this.triggers.forEach((t, i) => {
        if (i !== index) {
          this.collapse(i);
        }
      });
    }
    
    // Toggle current panel
    if (isExpanded) {
      this.collapse(index);
    } else {
      this.expand(index);
    }
  }
  
  expand(index) {
    const trigger = this.triggers[index];
    const panel = document.getElementById(trigger.getAttribute('aria-controls'));
    
    trigger.setAttribute('aria-expanded', 'true');
    panel.hidden = false;
  }
  
  collapse(index) {
    const trigger = this.triggers[index];
    const panel = document.getElementById(trigger.getAttribute('aria-controls'));
    
    trigger.setAttribute('aria-expanded', 'false');
    panel.hidden = true;
  }
}

// Initialize all accordions
document.querySelectorAll('.accordion').forEach(accordion => {
  new Accordion(accordion);
});
</script>
Accordion Variant Behavior Implementation
Single Expansion Opening one section closes others Default accordion behavior
Multiple Expansion Multiple sections can be open Add data-allow-multiple attribute
All Collapsed All sections can be closed Default - no section required to be open
Always One Open At least one section must be open Prevent closing last open section
Note: Use native <details>/<summary> when possible - it requires no JavaScript, works in all modern browsers, and has excellent accessibility support.

6. Custom Widget Development

Development Step Consideration Resources
1. Check Native Options Can native HTML element meet needs? HTML5 elements, form controls
2. Review ARIA Patterns Is there an established ARIA pattern? WAI-ARIA Authoring Practices Guide (APG)
3. Define Keyboard Interaction What keys should do what? Follow APG keyboard patterns
4. Implement ARIA Roles, states, properties ARIA specification
5. Add Focus Management Where should focus go when? Focus trap, roving tabindex patterns
6. Test with AT Screen readers, keyboard only NVDA, JAWS, VoiceOver, TalkBack

Example: Custom widget checklist and template

// Custom Widget Development Template
class CustomWidget {
  constructor(element) {
    this.element = element;
    this.isInitialized = false;
    
    // Validate element exists
    if (!this.element) {
      console.error('Widget element not found');
      return;
    }
    
    this.init();
  }
  
  init() {
    // 1. Set ARIA roles and initial state
    this.setupARIA();
    
    // 2. Setup keyboard interaction
    this.setupKeyboard();
    
    // 3. Setup mouse/touch interaction
    this.setupPointer();
    
    // 4. Setup focus management
    this.setupFocus();
    
    this.isInitialized = true;
  }
  
  setupARIA() {
    // Set role if not already present
    if (!this.element.hasAttribute('role')) {
      this.element.setAttribute('role', 'widget-role');
    }
    
    // Set initial ARIA states
    this.element.setAttribute('aria-label', 'Widget name');
    this.element.setAttribute('tabindex', '0');
    
    // Set dynamic ARIA properties
    this.updateARIAState();
  }
  
  updateARIAState() {
    // Update ARIA states based on widget state
    // Example: aria-expanded, aria-selected, aria-checked, etc.
  }
  
  setupKeyboard() {
    this.element.addEventListener('keydown', (e) => {
      switch(e.key) {
        case 'Enter':
        case ' ':
          e.preventDefault();
          this.activate();
          break;
        case 'ArrowUp':
        case 'ArrowDown':
        case 'ArrowLeft':
        case 'ArrowRight':
          e.preventDefault();
          this.navigate(e.key);
          break;
        case 'Home':
          e.preventDefault();
          this.navigateToFirst();
          break;
        case 'End':
          e.preventDefault();
          this.navigateToLast();
          break;
        case 'Escape':
          e.preventDefault();
          this.close();
          break;
        case 'Tab':
          // Allow default tab behavior unless in focus trap
          if (this.isFocusTrapped) {
            e.preventDefault();
            this.handleTabInTrap(e.shiftKey);
          }
          break;
      }
    });
  }
  
  setupPointer() {
    // Click events
    this.element.addEventListener('click', (e) => {
      this.handleClick(e);
    });
    
    // Touch events for mobile
    this.element.addEventListener('touchstart', (e) => {
      this.handleTouch(e);
    }, { passive: true });
  }
  
  setupFocus() {
    // Focus events
    this.element.addEventListener('focus', () => {
      this.onFocus();
    });
    
    this.element.addEventListener('blur', () => {
      this.onBlur();
    });
  }
  
  // Widget-specific methods
  activate() {
    console.log('Widget activated');
    this.updateARIAState();
  }
  
  navigate(direction) {
    console.log('Navigate:', direction);
  }
  
  navigateToFirst() {
    console.log('Navigate to first');
  }
  
  navigateToLast() {
    console.log('Navigate to last');
  }
  
  close() {
    console.log('Widget closed');
  }
  
  handleClick(e) {
    console.log('Clicked:', e.target);
  }
  
  handleTouch(e) {
    console.log('Touch:', e.target);
  }
  
  onFocus() {
    this.element.classList.add('focused');
  }
  
  onBlur() {
    this.element.classList.remove('focused');
  }
  
  handleTabInTrap(isShiftTab) {
    // Implement focus trap logic
  }
  
  // Public API
  destroy() {
    // Clean up event listeners
    this.element.removeAttribute('role');
    this.element.removeAttribute('tabindex');
    this.isInitialized = false;
  }
}

// Usage
const widget = new CustomWidget(document.getElementById('my-widget'));
Widget Checklist ✓ Verified Test Method
Keyboard Accessible Unplug mouse, navigate with keyboard only
Screen Reader Compatible Test with NVDA/JAWS/VoiceOver
Proper ARIA Roles Verify with browser DevTools
States Update Dynamically Check ARIA attributes change with state
Focus Visible Clear focus indicator on all elements
Focus Management Focus moves logically through widget
Touch Targets ≥44px Test on mobile device
Works Without JS Progressive enhancement (if possible)
Respects User Preferences Test prefers-reduced-motion, color schemes
Error States Announced Use aria-live or role="alert"
Cross-Browser Tested Chrome, Firefox, Safari, Edge
Mobile AT Tested iOS VoiceOver, Android TalkBack
Common Widget Types ARIA Pattern Reference Complexity
Accordion button[aria-expanded] + region Low
Tabs tablist + tab + tabpanel Medium
Dialog/Modal dialog + focus trap Medium
Dropdown Menu menu + menuitem Medium
Combobox combobox + listbox + active descendant High
Slider slider + aria-valuemin/max/now Medium
Data Grid grid + row + gridcell High
Tree View tree + treeitem + nested structure High
Tooltip tooltip + aria-describedby Low
Carousel region + group + rotation controls Medium-High
Warning: Custom widgets are difficult to get right. Before building custom, ask: Can native HTML solve this? Then: Can existing library solve this? Only build custom when absolutely necessary.
Resources:
  • ARIA Authoring Practices Guide (APG): w3.org/WAI/ARIA/apg/ - Definitive patterns for all widgets
  • Inclusive Components: inclusive-components.design - Real-world accessible component patterns
  • a11y-dialog: github.com/KittyGiraudel/a11y-dialog - Reference modal implementation
  • Reach UI: reach.tech - Accessible React component library to study

Interactive Components Quick Reference

  • Always use <button> for buttons - add type="button" to prevent form submission
  • Toggle buttons need aria-pressed, menu buttons need aria-expanded
  • Modals require: role="dialog", aria-modal="true", focus trap, Escape to close
  • Focus first element on modal open, restore focus on close
  • Use native <select> or <datalist> instead of custom combobox when possible
  • Dropdown menus use role="menu" with arrow key navigation
  • Tabs: roving tabindex, Left/Right arrow navigation, automatic activation recommended
  • Prefer <details>/<summary> for accordions - native and accessible
  • Custom widgets must implement keyboard interaction per ARIA Authoring Practices Guide
  • Test all widgets with keyboard only and multiple screen readers