DNA📄 HTMLAccessibility Deep Dive
ðŸĶ–DinosaurHTMLAccessibilityARIAa11y

Accessibility Deep Dive

Accessibility isn't a feature you bolt on at the end — it's an architecture decision that shapes how you build components, manage focus, and structure the DOM.

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

AttributeSourcePurposePriority
aria-labelledbyReferences other element(s) by IDPrimary accessible nameHighest
aria-labelString valuePrimary accessible nameMedium
<label> / contentElement's text contentPrimary accessible nameLowest
aria-describedbyReferences other element(s) by IDSupplementary descriptionAfter 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>
AttributeValuesEffect
aria-liveoff, polite, assertiveWhen to announce changes
aria-atomictrue, falseAnnounce entire region or just changed nodes
aria-relevantadditions, removals, text, allWhat 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 (::backdrop pseudo-element)
  • Closes on Escape key
  • Returns focus to the triggering element when closed
  • Sets role="dialog" and aria-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

LevelCriteriaTarget
AMinimum accessibilityLegal baseline
AAAddresses major barriersIndustry standard (most companies target this)
AAAHighest levelSpecialized 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

ToolTypeBest For
axe DevToolsBrowser extensionAutomated rule checking
LighthouseChrome built-inQuick accessibility score
VoiceOver (macOS)Screen readerManual testing on Mac
NVDAScreen readerManual testing on Windows
WAVEBrowser extensionVisual error overlay
eslint-plugin-jsx-a11yLinterCatch issues during development

Testing hierarchy:

  1. Automated tools catch ~30% of issues (missing labels, contrast, structure)
  2. Keyboard testing catches navigation and focus issues
  3. Screen reader testing catches announcement and interaction issues
  4. 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.