Accessibility Deep Dive
Accessibility (a11y) is where senior engineers separate themselves from mid-level developers. It's not about sprinkling aria-label on elements â it's about understanding how assistive technology consumes your UI and engineering your components to work correctly in that model.
The Accessibility Tree vs The DOM Tree
The browser maintains two parallel trees. The DOM tree is what you build. The accessibility tree is what assistive technology consumes:
DOM Tree Accessibility Tree
âââââââââ ââââââââââââââââââ
<div class="card"> (ignored â no semantic role)
<h2>Title</h2> â heading (level 2): "Title"
<p>Description</p> â paragraph: "Description"
<button>Read More</button>â button: "Read More"
<div class="spacer" /> (ignored â no content, no role)
</div>Key insight: Not every DOM node appears in the accessibility tree. Elements with no semantic role, no content, and no interactive behavior are pruned. Conversely, ARIA attributes can add nodes that don't exist in the DOM.
You can inspect this tree in Chrome DevTools (Elements â Accessibility panel) or Firefox's Accessibility Inspector.
ARIA Roles Taxonomy
ARIA roles fall into four categories:
Landmark Roles
Define page regions for navigation:
<header role="banner"> <!-- Implicit from <header> -->
<nav role="navigation"> <!-- Implicit from <nav> -->
<main role="main"> <!-- Implicit from <main> -->
<aside role="complementary"> <!-- Implicit from <aside> -->
<footer role="contentinfo"> <!-- Implicit from <footer> -->
<section role="region"> <!-- Only when section has aria-label -->
<form role="form"> <!-- Only when form has aria-label -->
<div role="search"> <!-- No native element equivalent -->Widget Roles
Define interactive components:
<div role="tablist">
<button role="tab" aria-selected="true">Tab 1</button>
<button role="tab" aria-selected="false">Tab 2</button>
</div>
<div role="tabpanel">Panel 1 content</div>Common widget roles: dialog, alertdialog, tab, tablist, tabpanel, menu, menuitem, tree, treeitem, grid, slider, toolbar, tooltip. Structure roles define document structure (list, listitem, table, row, heading). Live region roles define dynamic areas (alert, status, log, timer).
The First Rule of ARIA
Don't use ARIA. If you can use a native HTML element with the semantics you need, do that instead.
This isn't a joke â it's the W3C's official first rule. Native elements come with:
- Built-in keyboard handling
- Implicit ARIA roles
- Focus management
- Platform-consistent behavior
<!-- â Reimplementing a button with ARIA -->
<div role="button" tabindex="0"
aria-pressed="false"
onkeydown="if(e.key==='Enter'||e.key===' ') toggle()"
onclick="toggle()">
Toggle
</div>
<!-- â
Using the native element -->
<button onclick="toggle()">Toggle</button>The <div> version requires you to implement: focusability, keyboard activation (Enter + Space), role announcement, state management, and disabled state. The <button> handles all of this natively.
ARIA States & Properties
aria-expanded
<button aria-expanded="false" aria-controls="menu-items">Menu</button>
<ul id="menu-items" hidden>
<li><a href="/home">Home</a></li>
</ul>button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
menuItems.hidden = expanded;
});aria-label vs aria-labelledby vs aria-describedby
| Attribute | Source | Purpose | Priority |
|---|---|---|---|
aria-labelledby | References other element(s) by ID | Primary accessible name | Highest |
aria-label | String value | Primary accessible name | Medium |
<label> / content | Element's text content | Primary accessible name | Lowest |
aria-describedby | References other element(s) by ID | Supplementary description | After name |
<!-- aria-labelledby: name from another element -->
<h2 id="billing-title">Billing Address</h2>
<form aria-labelledby="billing-title">...</form>
<!-- aria-label: name from string -->
<button aria-label="Close dialog">Ã</button>
<!-- aria-describedby: supplementary description -->
<input id="password" type="password"
aria-describedby="pw-requirements" />
<p id="pw-requirements">Must be 8+ characters with one number</p>Name computation precedence: aria-labelledby > aria-label > <label> > title > placeholder (avoid relying on placeholder for accessible name).
Focus Management
tabindex
<!-- tabindex="0": Element is focusable in natural tab order -->
<div tabindex="0" role="button">Custom Widget</div>
<!-- tabindex="-1": Focusable via JS only, removed from tab order -->
<div tabindex="-1" id="error-message">Error occurred</div>
<!-- tabindex="1+": â NEVER USE â overrides natural order -->
<input tabindex="3" /> <!-- Creates maintenance nightmare -->Rule: Only use tabindex="0" (add to tab order) or tabindex="-1" (programmatic focus). Positive values break natural flow.
Focus Trapping (Modals)
When a modal opens, focus must be trapped inside it:
function trapFocus(modal) {
const focusable = modal.querySelectorAll(
'a[href], button, input, textarea, select, [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) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
first.focus();
}Roving Tabindex
For composite widgets (tablists, toolbars, menus), only one item is in the tab order at a time. Arrow keys move focus internally:
function rovingTabindex(container, items) {
let current = 0;
items.forEach((item, i) => item.setAttribute('tabindex', i === 0 ? '0' : '-1'));
container.addEventListener('keydown', (e) => {
let next = current;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (current + 1) % items.length;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (current - 1 + items.length) % items.length;
else return;
e.preventDefault();
items[current].setAttribute('tabindex', '-1');
items[next].setAttribute('tabindex', '0');
items[next].focus();
current = next;
});
}Skip Links
The first focusable element on the page should be a skip link:
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header><!-- navigation with 30 links --></header>
<main id="main-content" tabindex="-1">
<!-- primary content -->
</main>
</body>.skip-link { position: absolute; top: -100%; left: 0; z-index: 100; padding: 1rem; }
.skip-link:focus { top: 0; }Without this, keyboard users must tab through every navigation link on every page load.
Live Regions
Dynamic content updates need to be announced to screen readers:
<!-- polite: announced at next pause -->
<div aria-live="polite" aria-atomic="true">
3 items in your cart
</div>
<!-- assertive: announced immediately, interrupting current speech -->
<div aria-live="assertive" role="alert">
Session expiring in 30 seconds!
</div>| Attribute | Values | Effect |
|---|---|---|
aria-live | off, polite, assertive | When to announce changes |
aria-atomic | true, false | Announce entire region or just changed nodes |
aria-relevant | additions, removals, text, all | What types of changes to announce |
Critical pattern: The live region container must exist in the DOM before content changes. Dynamically injecting a container with aria-live won't work reliably.
Accessible Modals with <dialog>
The <dialog> element provides built-in modal behavior:
<dialog id="confirm-dialog">
<h2>Confirm Action</h2>
<p>Are you sure you want to delete this item?</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>const dialog = document.getElementById('confirm-dialog');
dialog.showModal();
dialog.addEventListener('close', () => {
console.log(dialog.returnValue); // "cancel" or "confirm"
});showModal() automatically:
- Traps focus inside the dialog
- Adds backdrop (
::backdroppseudo-element) - Closes on
Escapekey - Returns focus to the triggering element when closed
- Sets
role="dialog"andaria-modal="true"
Accessible Data Tables
Tables need <caption> for an accessible name and scope attributes to associate headers with data cells. Screen readers announce headers contextually: "Region column, North America row, Revenue: $2.1M."
<table>
<caption>Q1 2025 Revenue by Region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North America</th>
<td>$2.1M</td>
</tr>
</tbody>
</table>WCAG Compliance Levels
| Level | Criteria | Target |
|---|---|---|
| A | Minimum accessibility | Legal baseline |
| AA | Addresses major barriers | Industry standard (most companies target this) |
| AAA | Highest level | Specialized audiences; rarely full-site |
Color contrast requirements (AA):
- Normal text: 4.5:1 ratio
- Large text (18px+ bold or 24px+): 3:1 ratio
- Non-text UI components: 3:1 ratio
Testing Tools
| Tool | Type | Best For |
|---|---|---|
| axe DevTools | Browser extension | Automated rule checking |
| Lighthouse | Chrome built-in | Quick accessibility score |
| VoiceOver (macOS) | Screen reader | Manual testing on Mac |
| NVDA | Screen reader | Manual testing on Windows |
| WAVE | Browser extension | Visual error overlay |
eslint-plugin-jsx-a11y | Linter | Catch issues during development |
Testing hierarchy:
- Automated tools catch ~30% of issues (missing labels, contrast, structure)
- Keyboard testing catches navigation and focus issues
- Screen reader testing catches announcement and interaction issues
- User testing with people who have disabilities catches real-world problems
Interview Mental Model
Accessibility Architecture
âââ Semantic Foundation â Native HTML elements (first rule of ARIA)
âââ Accessibility Tree â Browser's parallel structure for AT
âââ Naming & Description â aria-labelledby > aria-label > label > title
âââ Focus Management â tabindex, trapping, roving, skip links
âââ Dynamic Content â Live regions (aria-live, aria-atomic)
âââ Testing â Automated (30%) + Manual (keyboard + screen reader)When asked about accessibility, don't list ARIA attributes â describe the user experience for someone using a screen reader or keyboard. "When this modal opens, focus moves to the first interactive element, Tab cycles through the modal's controls, Escape closes it and returns focus to the trigger." That's what separates a senior answer from a junior one.