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