All topics
Frontend · Learning hub

Accessibility notes for developers

Master Accessibility with a curated set of 2 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Frontend notes
Accessibility

Semantic HTML, ARIA & Landmarks

Semantic HTML, ARIA & Landmarks Start with native elements. Reach for ARIA only when the platform does not give you the role you need. The First Rule of ARIA No

Semantic HTML, ARIA & Landmarks

Start with native elements. Reach for ARIA only when the platform does not give you the role you need.

The First Rule of ARIA

No ARIA is better than bad ARIA. If a native HTML element does the job, use it. ARIA is a patch layer for components without a native equivalent (combobox, tablist, dialog), not a replacement for div soup.

<!-- BAD — div pretending to be a button -->
<div onclick="submit()" class="btn">Save</div>
<!-- Not focusable, no keyboard, no role announced -->

<!-- GOOD — real button -->
<button type="button" onclick="submit()">Save</button>
<!-- Free: focus, Enter/Space activation, role, disabled state -->

<!-- BAD — link as button -->
<a href="#" onclick="openModal()">Open</a>
<!-- Should be button — there's no navigation -->

<!-- BAD — list of divs -->
<div class="nav">
  <div>Home</div><div>About</div>
</div>

<!-- GOOD — semantic list inside a landmark -->
<nav aria-label="Main">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

Landmarks & Document Structure

<!-- Screen reader users jump between landmarks. Use them, exactly one of each (except nav). -->
<header>             <!-- role="banner" -->
  <a href="/">Logo</a>
  <nav aria-label="Primary">...</nav>
</header>

<main>               <!-- role="main" — one per page -->
  <h1>Page title</h1>
  <article>...</article>
</main>

<aside>              <!-- role="complementary" -->
  <h2>Related</h2>
</aside>

<footer>             <!-- role="contentinfo" -->
  <nav aria-label="Legal">...</nav>
</footer>

<!-- Heading hierarchy must not skip levels.
     h1 → h2 → h3, never h1 → h3. Visual size is independent of level. -->

<!-- Multiple <nav> elements? Distinguish with aria-label. -->
<nav aria-label="Breadcrumb">...</nav>
<nav aria-label="Pagination">...</nav>

Forms & Labels

<!-- Every input needs an associated label. Placeholder ≠ label. -->
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>

<!-- Or wrap (no `for` needed) -->
<label>
  Email
  <input name="email" type="email">
</label>

<!-- Group related inputs -->
<fieldset>
  <legend>Shipping address</legend>
  <label>Street <input name="street"></label>
  <label>City   <input name="city"></label>
</fieldset>

<!-- Error messages — programmatic association so SR reads them on focus -->
<label for="pw">Password</label>
<input id="pw" type="password" aria-invalid="true"
       aria-describedby="pw-err pw-hint">
<p id="pw-hint">At least 8 characters.</p>
<p id="pw-err" role="alert">Too short.</p>

<!-- autocomplete attributes help SR and password managers alike -->
<input autocomplete="current-password">
<input autocomplete="one-time-code">
<input autocomplete="cc-number">

ARIA Live Regions

<!-- Announces dynamic content to screen readers without moving focus -->

<!-- aria-live="polite" — wait until SR is idle -->
<div aria-live="polite">Saved!</div>

<!-- aria-live="assertive" — interrupt immediately. Use sparingly. -->
<div role="alert">Connection lost</div>   <!-- role=alert = assertive -->

<!-- aria-live="off" / removal — silent -->

<!-- aria-atomic="true" — read the whole region, not just the diff -->
<div aria-live="polite" aria-atomic="true">
  <p>3 of 10 items</p>
</div>

<!-- Gotcha: the live region must exist in the DOM BEFORE the content
     gets inserted. Mounting an empty <div role="alert"> on first use
     and then changing its text is the reliable pattern. -->

Common ARIA Attributes

Attribute             Use
────────────────────  ──────────────────────────────────────────────
role="..."            Override or fill in missing semantics
aria-label            Accessible name when no visible label
aria-labelledby="id"  Reference visible text as the name
aria-describedby=id   Extra description (hint, error)
aria-expanded         Disclosure / accordion / dropdown state
aria-controls="id"    What this control toggles
aria-current="page"   Marks the active item in a list
aria-hidden="true"    Hide decorative element from assistive tech
aria-disabled="true"  Disabled but still focusable (vs. `disabled`)
aria-pressed          Toggle button state
aria-selected         Selected option in listbox / tab
aria-modal="true"     On a dialog — traps SR cursor inside

Avoid: aria-role (it's `role`), redundant aria-label on <button>Save</button>.
Accessibility

Keyboard, Focus & Testing

Keyboard, Focus & Testing Tab order, focus management, skip links, color contrast, and the tooling that catches regressions before users do. Keyboard Navigation

Keyboard, Focus & Testing

Tab order, focus management, skip links, color contrast, and the tooling that catches regressions before users do.

Keyboard Navigation

  • Every interactive element must be reachable and operable with the keyboard alone. Test by unplugging your mouse for 5 minutes.

  • Tab order follows DOM order. Avoid `tabindex` values > 0 — they create surprising jumps.

  • tabindex="0" — make a non-focusable element focusable (custom widgets).

  • tabindex="-1" — focusable programmatically (.focus()) but skipped by Tab.

  • Escape closes dialogs, popovers, menus. Always. Without exception.

<!-- Skip link — first focusable element, lets keyboard users skip the nav -->
<a href="#main" class="skip-link">Skip to main content</a>
<style>
  .skip-link {
    position: absolute; top: -40px; left: 0;
    background: #000; color: #fff; padding: 8px 16px;
    z-index: 100;
  }
  .skip-link:focus { top: 0; }   /* visible only when focused */
</style>

<header><nav>...</nav></header>
<main id="main" tabindex="-1">...</main>

<!-- Custom focus indicator — never `outline: none` without a replacement -->
<style>
  :focus-visible {
    outline: 2px solid #06f;
    outline-offset: 2px;
  }
</style>

Focus Management in Dialogs

// Modal dialog opening behavior:
//   1. Save the element that had focus (the trigger)
//   2. Move focus into the dialog (first focusable, or the dialog itself)
//   3. Trap focus inside while open (Tab cycles within)
//   4. Esc closes
//   5. Return focus to the trigger on close

// In 2024+, prefer the native <dialog> element — handles all of this:
const dialog = document.querySelector('dialog')
dialog.showModal()    // backdrop, focus trap, Esc-to-close — free
dialog.close()        // restores focus to invoker

// React: Radix UI, Headless UI, react-aria — battle-tested primitives.
// Don't hand-roll focus traps unless you have a strong reason.

// Single-page app route changes — focus the new <main> or <h1>:
function onRouteChange() {
  const main = document.getElementById('main')
  main?.focus()
  // Optionally announce: a polite live region with the new page title
}

Color Contrast & Motion

WCAG 2.2 contrast targets (text vs background):
  Normal text     ≥ 4.5:1   (AA)   ≥ 7:1   (AAA)
  Large text      ≥ 3:1     (AA)   ≥ 4.5:1 (AAA)
  Non-text UI     ≥ 3:1     (AA)   — buttons, focus rings, icons

Large = 24px+ or 19px+ bold.

Tools:
  Chrome DevTools — Inspect → Contrast ratio shown on color picker
  axe DevTools    — flags failing combinations
  WebAIM checker  — quick HEX comparison

Motion & animation:
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.01ms !important;
      transition-duration: 0.01ms !important;
    }
  }
  Respects user OS setting. Required for AA conformance.

Testing

// Automated tools catch ~30% of issues. The other 70% needs human checks.

// 1) axe-core in unit tests
import {axe} from 'jest-axe'
it('has no a11y violations', async () => {
  const {container} = render(<MyForm />)
  expect(await axe(container)).toHaveNoViolations()
})

// 2) Playwright + @axe-core/playwright in e2e
import AxeBuilder from '@axe-core/playwright'
test('home a11y', async ({page}) => {
  await page.goto('/')
  const results = await new AxeBuilder({page}).analyze()
  expect(results.violations).toEqual([])
})

// 3) Lighthouse CI in your pipeline — accessibility score must stay ≥ 95

// 4) Manual checks no tool can do:
//      - Tab through the entire page — order makes sense, focus visible
//      - Use VoiceOver (Cmd+F5) / NVDA — does it sound right?
//      - Zoom to 200% — does anything break or get cut off?
//      - Try forms with autofill — do labels match autocomplete tokens?
//      - Read out loud the alt text — does it duplicate nearby caption?

Quick Audit Checklist

  • Page has exactly one <h1>; heading levels do not skip.

  • Every image has alt text (or alt="" if purely decorative).

  • All form inputs have visible labels and an autocomplete value where applicable.

  • Focus is visible on every interactive element (no outline removal without replacement).

  • Color contrast meets AA. Information is not conveyed by color alone.

  • Dialogs trap focus, return it on close, and respond to Esc.

  • prefers-reduced-motion is respected for animations.

  • Page is navigable end-to-end with the keyboard only.

Keep your Accessibility knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever