gap·map

Accessibility & ARIA

[ junior depth ]

ARIA is a contract you must then fulfill

The first rule of ARIA: if a native element already does the job, use it and write no ARIA at all. The reason sits in what a role is. A role is a promise, not an implementation:

<div role="button" onclick="save()">Save</div>

role="button" changes exactly one thing: assistive tech now announces "Save, button". The div is still not in the tab order, still ignores Enter and Space, still has no disabled semantics. The user was told it's a button; making that true (tabindex="0", keydown for Enter and Space, focus styles) is entirely on you. <button> ships all of it for free. This is why the WebAIM data shows pages using ARIA average more errors: an unfulfilled role is a lie told directly to the user.

❌ "Adding role='button' makes it clickable/keyboard-operable". A role changes the announcement. You still write the behavior.

Second habit: don't overwrite native semantics. <h2 role="tab"> destroys the heading. Roles say what a thing is; states (aria-expanded, aria-selected, aria-checked) say how it currently is, and states you must keep updated with JavaScript, or the widget lies.

How a control gets its accessible name

Every control gets announced by its accessible name, computed by precedence:

aria-labelledbyaria-label → native (<label>, alt) → text content → title (last resort).

<button aria-label="Close dialog">×</button>  <!-- announced "Close dialog" -->

Two traps here. aria-label only works on things with a role; a plain div or span wrapper ignores it. And on an element with visible text, aria-label replaces that text rather than adding to it.

The keyboard baseline

Everything interactive must be reachable with Tab and operable with Enter/Space, in an order that follows the DOM, with focus you can see. outline: none without a visible replacement ships an unusable page for keyboard users.

tabindex has exactly two useful values: 0 (join the natural tab order) and -1 (skip the tab order, but focusable from script). Positive values hijack the queue page-wide, so never use them.

❌ "tabindex='5' fine-tunes the order". Every positive value jumps ahead of the entire page and rots on the next edit. Fix the DOM order instead.

aria-hidden vs display:none

aria-hidden="true" removes an element from assistive tech and changes nothing visually; it exists for decorative icons. display: none (and hidden) removes it from everyone, screen readers included. Keeping something visible for sighted users but silent, or invisible but announced, are different tools. The full hiding taxonomy, and what a role like menu obligates you to build, is middle-level material.

[ middle depth ]

Every role imports a keyboard spec

The ARIA Authoring Practices (APG) define, per widget, the keys users expect. Claim a role and you owe the whole pattern. The dialog is the canonical example:

  • In: on open, focus moves inside, to the sensible first element (or a tabindex="-1" heading for content-heavy dialogs).
  • Trapped: Tab and Shift+Tab wrap within the dialog; background content is inert.
  • Out: Escape closes; focus returns to the invoker. Skip the restore and focus falls to <body>, which teleports the user to the top of the page.
  • Announced: role="dialog", aria-modal="true", named via aria-labelledby pointing at the title.

Native <dialog>.showModal() plus inert on the background gives you the trap, top-layer, and Escape. Hand-rolled keydown traps are the legacy path. Only the focus restore is still your job.

Roving tabindex and aria-activedescendant

Composite widgets (toolbar, tabs, menu, radio group) follow one convention: Tab moves between widgets, arrows move within. The mechanism is the roving tabindex: one item at tabindex="0", the rest at -1; arrow keys move both the 0 and .focus():

<div role="toolbar">
  <button tabindex="0">Bold</button>
  <button tabindex="-1">Italic</button>
</div>

The alternative is aria-activedescendant: DOM focus stays on the container (a combobox's input), and an IDREF tells assistive tech which option is "focused". A combobox wires exactly this: role="combobox" + aria-expanded (flipped on open/close) + aria-controls + aria-activedescendant, Down opens, Escape closes, Enter accepts. aria-expanded frozen at one value is a widget that lies about its state.

How live regions announce changes

Content that changes without focus moving (toasts, search counts, validation) needs a live region. aria-live="polite" waits for the user to idle; assertive interrupts mid-sentence and belongs on errors only. role="status" and role="alert" are the shortcuts.

The classic failure: screen readers watch existing regions for changes. Render the region empty up front, then set its text:

<div role="status"></div>   <!-- in the DOM from the start -->
<script>statusEl.textContent = "3 results";</script>

❌ Inserting a fresh <div aria-live="polite">3 results</div> usually announces nothing. Multi-part values (17:34 from two spans) need aria-atomic="true" or only the changed fragment is read.

Four ways to hide content

TechniqueSighted usersScreen readers
visually-hidden CSS (clip 1px box)hiddenannounced
aria-hidden="true"visiblehidden
display:none / hiddenhiddenhidden
inertvisiblehidden + unfocusable

The dangerous combination is aria-hidden on something focusable: sighted keyboard users tab into it, screen reader users hear silence, a ghost tab stop. Keydown handlers on random divs also never fire for screen reader users. In browse mode the screen reader consumes keys itself and forwards them only to real interactive elements, so a widget built from roles without behavior fails twice.

[ senior depth ]

How the accessible name is computed

"What does this control announce?" has a deterministic answer. The accessible name is computed by precedence:

  1. aria-labelledby (can reference multiple ids, hidden elements, even itself)
  2. aria-label
  3. host language: <label>, alt, <legend>, <caption>
  4. name from content, only for roles that take it (button, link, heading, tab…)
  5. title, last resort

Interviewers probe the corollaries. aria-label on a button with visible text replaces the text: a voice-control user saying "click Submit" misses <button aria-label="send form">Submit</button> (WCAG's label-in-name rule exists for this). aria-label strings also routinely escape translation pipelines, so aria-labelledby pointing at visible, translated text is the safer default. And an <input> inside a <div>Assignee</div> has no name at all; adjacency is not association.

Where focus goes at app scale

The question at app scale is never "is this element focusable" but "where is focus after X?":

  • Route change in a SPA: no page load, so nothing announces. Move focus to the new view's tabindex="-1" heading (or announce via live region) and update document.title.
  • Element removal: delete a list item or close a menu, and focus silently drops to <body>. Re-home it yourself, to the next sibling or the list heading.
  • Layer stacks: portaled modals break DOM-order intuition; each layer must record its invoker and restore focus on close, LIFO, and inert the layers below instead of hand-rolling traps.

WCAG 2.2 sharpened exactly this surface, and its deltas are the ones frontend devs hit: Focus Not Obscured (your sticky header can't cover the focused element, AA), Target Size 24×24 CSS px minimum (AA), Dragging needs a single-pointer alternative (AA), plus Redundant Entry and Accessible Authentication. 4.1.1 Parsing is gone. AA remains the contractual and legal norm.

Testing without lying to yourself

Automated tools (axe, Lighthouse) catch roughly a third of real-world issues. They verify attributes and contrast; they cannot tell you whether the tab order makes sense, the name means anything, or the announcement matches the interaction. A green axe run on the roles-sprinkled dropdown is expected. The honest stack:

  • CI: axe on key pages + lint (eslint-plugin-jsx-a11y). Catches regressions cheaply, proves little.
  • Per feature: a manual keyboard pass. Tab through, operate everything, close everything, watch where focus lands.
  • Per flow: a screen reader smoke test (VoiceOver is already on the Mac you're holding; NVDA is free).
  • In component tests: assert the contract. Role-based queries and toHaveAccessibleName make tests fail when semantics regress; Testing Library's query priority is itself an a11y check.

Building accessibility into the process

A11y done as a sprint-end sweep is a permanent backlog; retrofits cost multiples of building it in. The leverage point is the component library: fix the shared Select once, correctly, and every feature inherits it. Add a keyboard pass to the definition of done, and review checklists that ask "where does focus go?" the way they ask "what if this fails?".

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more HTML & A11y