Advanced ARIA Techniques

1. Complex Widget Patterns

Widget Pattern Required Roles/Attributes Keyboard Behavior Use Case
Menu/Menubar role="menubar", role="menuitem", aria-haspopup, aria-expanded Arrow keys navigate, Enter/Space activate, Esc closes, Home/End to first/last Application menus, context menus, navigation menus
Toolbar role="toolbar", aria-label, aria-orientation Arrow keys navigate, Tab leaves toolbar, Home/End to first/last Rich text editors, formatting controls, action groups
Slider (Range) role="slider", aria-valuemin, aria-valuemax, aria-valuenow, aria-valuetext Arrow Up/Right increase, Arrow Down/Left decrease, Home/End to min/max, Page Up/Down for larger steps Volume controls, price ranges, rating systems
Spinbutton role="spinbutton", aria-valuemin, aria-valuemax, aria-valuenow Arrow Up/Down adjust value, Page Up/Down for larger steps, Home/End to min/max Numeric input with increment/decrement buttons
Feed role="feed", role="article", aria-setsize, aria-posinset, aria-busy Page Down/Up navigate articles, specific screen reader commands for feed navigation Infinite scroll, social media feeds, news streams
Carousel role="region", aria-label, aria-live="polite" (for auto-rotation), aria-roledescription="carousel" Previous/Next buttons, pause/play control, optional arrow key navigation Image galleries, featured content, product showcases

Example: Accessible Menubar Implementation

<nav role="menubar" aria-label="Main Navigation">
  <div role="menuitem" aria-haspopup="true" aria-expanded="false" tabindex="0">
    File
    <div role="menu" aria-label="File Menu" hidden>
      <div role="menuitem" tabindex="-1">New</div>
      <div role="menuitem" tabindex="-1">Open</div>
      <div role="separator"></div>
      <div role="menuitem" tabindex="-1">Save</div>
    </div>
  </div>
</nav>

<script>
// Keyboard navigation handler
menubar.addEventListener('keydown', (e) => {
  switch(e.key) {
    case 'ArrowRight': focusNextMenuItem(); break;
    case 'ArrowLeft': focusPrevMenuItem(); break;
    case 'Enter':
    case ' ': toggleSubmenu(); break;
    case 'Escape': closeSubmenu(); break;
  }
});
</script>

Example: Multi-thumb Slider Pattern

<div role="group" aria-labelledby="price-label">
  <span id="price-label">Price Range</span>
  <div class="slider-track">
    <div role="slider" 
         aria-label="Minimum price"
         aria-valuemin="0" 
         aria-valuemax="1000" 
         aria-valuenow="100"
         aria-valuetext="$100"
         tabindex="0"></div>
    <div role="slider" 
         aria-label="Maximum price"
         aria-valuemin="0" 
         aria-valuemax="1000" 
         aria-valuenow="500"
         aria-valuetext="$500"
         tabindex="0"></div>
  </div>
  <output aria-live="polite">$100 - $500</output>
</div>
Widget Pattern Best Practices:
  • Follow ARIA Authoring Practices Guide (APG) specifications exactly
  • Provide visible focus indicators for all interactive elements
  • Include keyboard shortcuts documentation (aria-keyshortcuts)
  • Test with multiple screen readers (NVDA, JAWS, VoiceOver, TalkBack)
  • Provide escape hatches (Esc key, close buttons) for all overlays
  • Use roving tabindex pattern for single-tab-stop widget groups

2. Virtual Focus Management

Technique Implementation ARIA Attributes Use Case
Active Descendant aria-activedescendant points to focused child ID aria-activedescendant="item-id", container gets focus Listboxes, comboboxes, trees, grids where container maintains focus
Roving Tabindex Only one focusable child (tabindex="0"), others tabindex="-1" Update tabindex on arrow key navigation Toolbars, menubars, tab lists, radio groups
Focus Trap Cycle focus within container, prevent Tab from leaving aria-modal="true", manage Tab/Shift+Tab events Modal dialogs, dropdown menus, overlays
Focus Restoration Store reference to previous focus, restore on close Use document.activeElement before opening widget Dialogs, drawers, any temporary widget
Programmatic Focus element.focus() with optional preventScroll Set tabindex="-1" on non-interactive targets Skip links, error messages, dynamic content updates
Virtual Cursor Custom focus indicator separate from browser focus aria-activedescendant, visual styling only Spreadsheets, complex grids, custom navigation systems

Example: Active Descendant Pattern (Listbox)

<div role="listbox" 
     aria-label="Color selection"
     aria-activedescendant="option-red"
     tabindex="0">
  <div role="option" id="option-red" aria-selected="true">Red</div>
  <div role="option" id="option-blue">Blue</div>
  <div role="option" id="option-green">Green</div>
</div>

<script>
let activeIndex = 0;
const listbox = document.querySelector('[role="listbox"]');
const options = listbox.querySelectorAll('[role="option"]');

listbox.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowDown') {
    activeIndex = Math.min(activeIndex + 1, options.length - 1);
    updateActiveDescendant();
  } else if (e.key === 'ArrowUp') {
    activeIndex = Math.max(activeIndex - 1, 0);
    updateActiveDescendant();
  }
});

function updateActiveDescendant() {
  const activeOption = options[activeIndex];
  listbox.setAttribute('aria-activedescendant', activeOption.id);
  
  // Update visual indicator
  options.forEach(opt => opt.classList.remove('focused'));
  activeOption.classList.add('focused');
  
  // Ensure visible (scroll into view)
  activeOption.scrollIntoView({ block: 'nearest' });
}
</script>

Example: Focus Trap for Modal Dialog

function createFocusTrap(element) {
  const focusableSelector = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])';
  const focusable = element.querySelectorAll(focusableSelector);
  const firstFocusable = focusable[0];
  const lastFocusable = focusable[focusable.length - 1];
  
  const previousFocus = document.activeElement;
  
  element.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable.focus();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable.focus();
        }
      }
    }
  });
  
  // Initial focus
  firstFocusable.focus();
  
  // Cleanup function
  return () => previousFocus.focus();
}
</script>
Virtual Focus Pitfalls: aria-activedescendant only works with specific roles (listbox, tree, grid, combobox). Browser focus must remain on container. Screen readers may not announce changes without proper role context. Always test with actual assistive technologies. Roving tabindex requires maintaining state correctly. Focus trap must allow Esc key exit.

3. Multi-Select Components

Component Type ARIA Pattern Selection Behavior Keyboard Shortcuts
Multi-select Listbox role="listbox", aria-multiselectable="true", aria-selected on options Click/Space toggle, Ctrl+Click add to selection, Shift+Click range select Ctrl+A select all, Space toggle current, Shift+Arrow range extend
Checkbox Group role="group", aria-labelledby, native checkboxes Each checkbox independently toggleable Tab between checkboxes, Space to toggle
Multi-select Combobox role="combobox", aria-multiselectable="true", aria-activedescendant Type to filter, Arrow to navigate, Enter to add to selection Type for autocomplete, Enter add, Backspace remove last, Esc close
Transfer List Two listboxes with buttons, aria-labelledby for associations Select in source list, button moves to destination Arrow navigate, Space select, Tab to buttons, Enter to transfer
Tag Input role="listbox" for tags, role="option" for each tag Type to add, click X or Backspace to remove Backspace removes last tag, Arrow keys navigate tags, Delete removes focused tag

Example: Multi-select Listbox with Range Selection

<div role="listbox" 
     aria-label="Select multiple items"
     aria-multiselectable="true"
     tabindex="0">
  <div role="option" aria-selected="false" id="opt-1">Option 1</div>
  <div role="option" aria-selected="false" id="opt-2">Option 2</div>
  <div role="option" aria-selected="false" id="opt-3">Option 3</div>
  <div role="option" aria-selected="false" id="opt-4">Option 4</div>
</div>
<output aria-live="polite" aria-atomic="true"></output>

<script>
let selectedIndices = new Set();
let lastSelectedIndex = -1;

function toggleSelection(index, rangeSelect = false) {
  const options = listbox.querySelectorAll('[role="option"]');
  
  if (rangeSelect && lastSelectedIndex !== -1) {
    // Range selection with Shift
    const start = Math.min(lastSelectedIndex, index);
    const end = Math.max(lastSelectedIndex, index);
    for (let i = start; i <= end; i++) {
      selectedIndices.add(i);
      options[i].setAttribute('aria-selected', 'true');
    }
  } else {
    // Toggle single selection
    if (selectedIndices.has(index)) {
      selectedIndices.delete(index);
      options[index].setAttribute('aria-selected', 'false');
    } else {
      selectedIndices.add(index);
      options[index].setAttribute('aria-selected', 'true');
    }
    lastSelectedIndex = index;
  }
  
  announceSelection();
}

function announceSelection() {
  const count = selectedIndices.size;
  document.querySelector('output').textContent = 
    `${count} item${count !== 1 ? 's' : ''} selected`;
}
</script>

Example: Tag Input Component

<div class="tag-input" role="group" aria-labelledby="tag-label">
  <span id="tag-label">Add tags:</span>
  <ul role="listbox" aria-label="Selected tags">
    <li role="option">
      JavaScript
      <button aria-label="Remove JavaScript tag">×</button>
    </li>
    <li role="option">
      React
      <button aria-label="Remove React tag">×</button>
    </li>
  </ul>
  <input type="text" 
         aria-label="Add new tag" 
         aria-autocomplete="list"
         aria-controls="tag-suggestions">
</div>

<div id="tag-suggestions" role="listbox" hidden>
  <div role="option">TypeScript</div>
  <div role="option">Node.js</div>
</div>
Multi-select Best Practices:
  • Announce selection count changes via live region
  • Provide "Select All" and "Clear Selection" actions
  • Visually distinguish selected items (checked, highlighted)
  • Support both mouse and keyboard selection methods
  • Document keyboard shortcuts in help text or tooltip
  • Consider mobile: provide checkboxes instead of Ctrl+Click

4. Data Grid Implementation

Grid Feature ARIA Implementation Keyboard Navigation Screen Reader Behavior
Basic Grid role="grid", role="row", role="columnheader", role="gridcell" Arrow keys navigate cells, Home/End row start/end, Ctrl+Home/End grid corners Announces row/column headers, position (e.g., "row 2 of 10, column 3 of 5")
Sortable Columns aria-sort="ascending|descending|none" on column headers Enter/Space on column header to sort Announces "sorted ascending/descending" when sort changes
Editable Cells aria-readonly="false", Enter to edit mode, Esc to cancel Enter/F2 enter edit mode, Esc cancel, Tab/Arrow exit and save Announces "editable" or "read-only" for each cell
Row Selection aria-selected="true" on row, aria-multiselectable on grid Click or Space on row to select, Ctrl+Click multi-select, Shift+Arrow range Announces "selected" state change
Expandable Rows aria-expanded on row, aria-level for hierarchy, aria-setsize/aria-posinset Arrow Right expands, Arrow Left collapses, * expands all siblings Announces expanded/collapsed state and nesting level
Column Spanning aria-colspan, aria-rowspan on gridcell Navigation skips spanned cells appropriately Announces spanning information (e.g., "spans 2 columns")

Example: Accessible Data Grid with Sorting and Navigation

<div role="grid" aria-label="Product inventory" aria-rowcount="100">
  <div role="rowgroup">
    <div role="row" aria-rowindex="1">
      <span role="columnheader" aria-sort="ascending" aria-colindex="1">
        <button>Product Name</button>
      </span>
      <span role="columnheader" aria-sort="none" aria-colindex="2">
        <button>Price</button>
      </span>
      <span role="columnheader" aria-sort="none" aria-colindex="3">
        <button>Stock</button>
      </span>
    </div>
  </div>
  
  <div role="rowgroup">
    <div role="row" aria-rowindex="2">
      <span role="gridcell" aria-colindex="1" tabindex="0">Widget A</span>
      <span role="gridcell" aria-colindex="2" tabindex="-1">$29.99</span>
      <span role="gridcell" aria-colindex="3" tabindex="-1">42</span>
    </div>
    <div role="row" aria-rowindex="3">
      <span role="gridcell" aria-colindex="1" tabindex="-1">Widget B</span>
      <span role="gridcell" aria-colindex="2" tabindex="-1">$39.99</span>
      <span role="gridcell" aria-colindex="3" tabindex="-1">17</span>
    </div>
  </div>
</div>

<script>
// Arrow key navigation
grid.addEventListener('keydown', (e) => {
  const cell = document.activeElement.closest('[role="gridcell"]');
  if (!cell) return;
  
  let targetCell;
  switch(e.key) {
    case 'ArrowRight': targetCell = getNextCell(cell); break;
    case 'ArrowLeft': targetCell = getPrevCell(cell); break;
    case 'ArrowDown': targetCell = getCellBelow(cell); break;
    case 'ArrowUp': targetCell = getCellAbove(cell); break;
    case 'Home': 
      targetCell = e.ctrlKey ? getFirstCell() : getFirstCellInRow(cell);
      break;
    case 'End':
      targetCell = e.ctrlKey ? getLastCell() : getLastCellInRow(cell);
      break;
  }
  
  if (targetCell) {
    e.preventDefault();
    targetCell.focus();
  }
});
</script>

Example: Editable Grid Cell Pattern

<span role="gridcell" tabindex="0" aria-readonly="false">
  <span class="cell-content">Original Value</span>
  <input type="text" class="cell-editor" hidden>
</span>

<script>
function enterEditMode(cell) {
  const content = cell.querySelector('.cell-content');
  const editor = cell.querySelector('.cell-editor');
  
  editor.value = content.textContent;
  content.hidden = true;
  editor.hidden = false;
  editor.focus();
  
  cell.setAttribute('aria-readonly', 'false');
}

function exitEditMode(cell, save = true) {
  const content = cell.querySelector('.cell-content');
  const editor = cell.querySelector('.cell-editor');
  
  if (save) {
    content.textContent = editor.value;
  }
  
  content.hidden = false;
  editor.hidden = true;
  cell.focus();
}

gridcell.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === 'F2') {
    enterEditMode(gridcell);
  }
});

editor.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    exitEditMode(gridcell, false);
  } else if (e.key === 'Enter') {
    exitEditMode(gridcell, true);
  }
});
</script>
Data Grid Complexity: Full grid implementation is one of the most complex ARIA patterns. Consider using established libraries (ag-Grid, TanStack Table) with built-in accessibility. Virtualized grids require aria-rowcount/aria-rowindex. Large grids may overwhelm screen readers - provide filtering/search. Test extensively with keyboard only and screen readers.

5. Tree View and Hierarchical Data

Tree Feature ARIA Attributes Keyboard Behavior Implementation Notes
Tree Container role="tree", aria-label or aria-labelledby Tab enters tree, Arrow keys navigate, Enter activates Single tab stop for entire tree (roving tabindex on items)
Tree Items role="treeitem", aria-level, aria-setsize, aria-posinset First child has tabindex="0", others tabindex="-1" Level starts at 1 for root items, increments for children
Expandable Nodes aria-expanded="true|false", role="group" for children Arrow Right expands, Arrow Left collapses, * expands all siblings Hide children when collapsed (display: none or hidden)
Selection aria-selected="true|false", aria-multiselectable on tree Space selects/deselects, Ctrl+Space multi-select, Shift+Arrow range Visual indicator required for selected state
Navigation Focus management with roving tabindex Arrow Up/Down traverse visible items, Home/End first/last item Skip collapsed children when navigating
Multi-select Tree aria-multiselectable="true" on tree Ctrl+Arrow move focus without selection, Ctrl+Space toggle selection Separate focus from selection state

Example: File System Tree View

<div role="tree" aria-label="File system" aria-multiselectable="false">
  <div role="treeitem" 
       aria-expanded="true" 
       aria-level="1" 
       aria-setsize="2" 
       aria-posinset="1"
       tabindex="0">
    <span>📁 Documents</span>
    <div role="group">
      <div role="treeitem" 
           aria-level="2" 
           aria-setsize="2" 
           aria-posinset="1"
           tabindex="-1">
        <span>📄 Report.pdf</span>
      </div>
      <div role="treeitem" 
           aria-expanded="false"
           aria-level="2" 
           aria-setsize="2" 
           aria-posinset="2"
           tabindex="-1">
        <span>📁 Archives</span>
        <div role="group" hidden>
          <div role="treeitem" aria-level="3" tabindex="-1">
            <span>📄 2023.zip</span>
          </div>
        </div>
      </div>
    </div>
  </div>
  
  <div role="treeitem" 
       aria-expanded="false"
       aria-level="1" 
       aria-setsize="2" 
       aria-posinset="2"
       tabindex="-1">
    <span>📁 Pictures</span>
    <div role="group" hidden></div>
  </div>
</div>

<script>
class TreeView {
  constructor(treeElement) {
    this.tree = treeElement;
    this.items = Array.from(treeElement.querySelectorAll('[role="treeitem"]'));
    this.focusedItem = this.items.find(item => item.tabIndex === 0);
    
    this.tree.addEventListener('keydown', this.handleKeydown.bind(this));
    this.tree.addEventListener('click', this.handleClick.bind(this));
  }
  
  handleKeydown(e) {
    const item = e.target.closest('[role="treeitem"]');
    if (!item) return;
    
    switch(e.key) {
      case 'ArrowDown':
        e.preventDefault();
        this.focusNextVisible();
        break;
      case 'ArrowUp':
        e.preventDefault();
        this.focusPrevVisible();
        break;
      case 'ArrowRight':
        e.preventDefault();
        if (item.getAttribute('aria-expanded') === 'false') {
          this.expand(item);
        } else {
          this.focusFirstChild(item);
        }
        break;
      case 'ArrowLeft':
        e.preventDefault();
        if (item.getAttribute('aria-expanded') === 'true') {
          this.collapse(item);
        } else {
          this.focusParent(item);
        }
        break;
      case 'Home':
        e.preventDefault();
        this.focusFirst();
        break;
      case 'End':
        e.preventDefault();
        this.focusLast();
        break;
      case '*':
        e.preventDefault();
        this.expandAllSiblings(item);
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        this.toggleExpanded(item);
        break;
    }
  }
  
  expand(item) {
    const group = item.querySelector('[role="group"]');
    if (group) {
      item.setAttribute('aria-expanded', 'true');
      group.hidden = false;
    }
  }
  
  collapse(item) {
    const group = item.querySelector('[role="group"]');
    if (group) {
      item.setAttribute('aria-expanded', 'false');
      group.hidden = true;
    }
  }
  
  setFocused(item) {
    if (this.focusedItem) {
      this.focusedItem.tabIndex = -1;
    }
    item.tabIndex = 0;
    item.focus();
    this.focusedItem = item;
  }
}
</script>
Tree View Best Practices:
  • Announce tree structure: "level X of Y, item N of M"
  • Provide visual expansion indicators (arrows, +/- icons)
  • Consider lazy loading for large trees (update aria-setsize dynamically)
  • Support type-ahead search: focus item starting with typed character
  • Persist expanded/collapsed state across sessions when appropriate
  • Test with screen readers - tree navigation varies by SR

6. Custom Live Region Strategies

Strategy Implementation Use Case Timing Considerations
Debounced Updates Delay announcements until user stops typing/interacting Search suggestions, form validation, character counters 300-500ms delay typical, prevents announcement spam
Atomic Regions aria-atomic="true" announces entire region even if partial update Status messages, scoreboard, timer displays Use when context is lost with partial updates
Relevant Property aria-relevant="additions|removals|text|all" controls what changes announce Chat messages (additions), todo lists (additions removals), counters (text) Default is "additions text", be specific to reduce noise
Priority Queueing Use aria-live="assertive" for urgent, polite for non-urgent Errors are assertive, status updates are polite Assertive interrupts current speech, use sparingly
Progressive Disclosure Announce summary first, provide details on demand Long notifications, complex updates, batch operations E.g., "5 items updated" vs reading all 5 items
Visual-Only Updates Use aria-hidden="true" for decorative or redundant updates Loading spinners (when status text exists), progress bars with labels Prevent double-announcement of same information

Example: Debounced Live Region for Search Results

<label for="search">Search products</label>
<input type="search" 
       id="search" 
       aria-controls="results-status"
       aria-describedby="results-status">

<div id="results-status" 
     role="status" 
     aria-live="polite" 
     aria-atomic="true">
</div>

<div id="results-list"></div>

<script>
let debounceTimer;

searchInput.addEventListener('input', (e) => {
  clearTimeout(debounceTimer);
  
  // Show loading state immediately (visual only)
  resultsStatus.textContent = 'Searching...';
  resultsStatus.setAttribute('aria-busy', 'true');
  
  // Debounce the actual announcement
  debounceTimer = setTimeout(async () => {
    const query = e.target.value;
    const results = await searchProducts(query);
    
    // Update results
    displayResults(results);
    
    // Announce count (this triggers screen reader)
    resultsStatus.setAttribute('aria-busy', 'false');
    resultsStatus.textContent = 
      `${results.length} result${results.length !== 1 ? 's' : ''} found for "${query}"`;
  }, 500);
});
</script>

Example: Custom Notification Queue System

<!-- Separate regions for different priority levels -->
<div role="alert" aria-live="assertive" class="sr-only"></div>
<div role="status" aria-live="polite" class="sr-only"></div>

<script>
class NotificationManager {
  constructor() {
    this.assertiveRegion = document.querySelector('[role="alert"]');
    this.politeRegion = document.querySelector('[role="status"]');
    this.queue = [];
    this.isAnnouncing = false;
  }
  
  announce(message, priority = 'polite', delay = 0) {
    setTimeout(() => {
      if (priority === 'assertive') {
        // Clear and announce immediately
        this.assertiveRegion.textContent = '';
        setTimeout(() => {
          this.assertiveRegion.textContent = message;
        }, 100);
      } else {
        // Queue polite announcements
        this.queue.push(message);
        this.processQueue();
      }
    }, delay);
  }
  
  async processQueue() {
    if (this.isAnnouncing || this.queue.length === 0) return;
    
    this.isAnnouncing = true;
    const message = this.queue.shift();
    
    // Clear region
    this.politeRegion.textContent = '';
    
    // Wait for screen reader to register the clear
    await new Promise(resolve => setTimeout(resolve, 100));
    
    // Announce message
    this.politeRegion.textContent = message;
    
    // Wait for announcement to complete
    await new Promise(resolve => setTimeout(resolve, 2000));
    
    this.isAnnouncing = false;
    this.processQueue();
  }
  
  clear() {
    this.queue = [];
    this.assertiveRegion.textContent = '';
    this.politeRegion.textContent = '';
  }
}

// Usage
const notify = new NotificationManager();
notify.announce('Form saved successfully', 'polite');
notify.announce('Error: Connection lost', 'assertive');
</script>

Example: Progressive Disclosure for Batch Operations

<button id="delete-selected">Delete Selected Items</button>
<div role="status" aria-live="polite" aria-atomic="true"></div>

<script>
async function deleteMultipleItems(itemIds) {
  const status = document.querySelector('[role="status"]');
  const total = itemIds.length;
  let completed = 0;
  
  // Initial announcement - summary only
  status.textContent = `Deleting ${total} items...`;
  
  for (const id of itemIds) {
    await deleteItem(id);
    completed++;
    
    // Progress updates every 25% or last item
    if (completed % Math.ceil(total / 4) === 0 || completed === total) {
      status.textContent = 
        completed === total 
          ? `Successfully deleted ${total} items`
          : `Deleted ${completed} of ${total} items`;
    }
  }
}

// Alternative: Provide details button
function deleteWithDetails(itemIds) {
  const status = document.querySelector('[role="status"]');
  
  status.innerHTML = `
    Deleted ${itemIds.length} items.
    <button onclick="showDeletedItems()">View details</button>
  `;
}
</script>
Live Region Gotchas: Live regions must exist in DOM on page load (can be empty). Changes to aria-live attribute itself are not announced. Rapid consecutive updates may be missed - use debouncing or queueing. Screen readers may ignore updates to hidden elements. Test with actual screen readers - behavior varies significantly. Use sparingly - too many announcements overwhelm users.

Advanced ARIA Techniques Summary

  • Complex Widgets: Follow ARIA APG patterns exactly; provide keyboard shortcuts; test with multiple screen readers
  • Virtual Focus: Use aria-activedescendant for list-like widgets; roving tabindex for toolbars/menus; always restore focus on close
  • Multi-select: Support Ctrl+Click, Shift+range, keyboard shortcuts; announce selection count; provide clear/select all
  • Data Grids: Most complex ARIA pattern; consider established libraries; provide row/column headers; support sorting and editing
  • Tree Views: Use aria-level, aria-expanded, roving tabindex; support keyboard navigation; lazy load large trees
  • Live Regions: Debounce rapid updates; use atomic for context; queue announcements; polite vs assertive by urgency
  • Testing: Keyboard-only navigation first; test with NVDA, JAWS, VoiceOver, TalkBack; validate with axe/Lighthouse
  • Performance: Virtual scrolling for large datasets; lazy loading with proper announcements; avoid excessive DOM updates