IntermediateJavaScript · Lesson 9 of 10

Accessibility (a11y)

Build pages everyone can use: semantic HTML, labels, keyboard support, focus management and ARIA.

Many people use the web with a keyboard only, a screen reader, zoom or voice control. Most accessibility comes free from semantic HTML: a real <button> is focusable and works with Enter and Space, <label for> gives every input a spoken name and a bigger click target, and headings and <main> let screen-reader users jump around the page. A <div onclick> has none of this.

Forms should explain problems in words, not only colour. Link each error message to its input with aria-describedby, mark the field with aria-invalid="true" and move focus to the first invalid field. A live region (role="status") announces messages such as "Saved" without moving focus. Show where focus is with :focus-visible styles. Never remove the outline without a replacement.

Use ARIA only to describe what HTML cannot: aria-expanded on a button that shows and hides content, roles for custom widgets such as tabs. The native <dialog> element with showModal() traps focus and closes on Escape; afterwards, return focus to where the user was. Test with only the keyboard (Tab, Shift+Tab, Enter, Escape) and run an automated checker such as Lighthouse or axe.

index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Register a student</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 1rem auto; padding: 0 1rem; }
      label { display: block; margin-top: 0.75rem; font-weight: 600; }
      input { display: block; padding: 0.4rem; font-size: 1rem; }
      [aria-invalid="true"] { border: 2px solid #b00020; }
      .error { color: #b00020; margin: 0.25rem 0 0; }
      :focus-visible { outline: 3px solid #1a73e8; outline-offset: 2px; }
      .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
    </style>
    <script src="app.js" defer></script>
  </head>
  <body>
    <main>
      <h1>Register a student</h1>
      <form id="register" novalidate>
        <label for="name">Full name</label>
        <input id="name" name="name" autocomplete="name" aria-describedby="name-error" />
        <p class="error" id="name-error"></p>

        <label for="phone">Parent's phone <span class="visually-hidden">(10 digits, starting with 0)</span></label>
        <input id="phone" name="phone" type="tel" inputmode="numeric" aria-describedby="phone-hint phone-error" />
        <p id="phone-hint">e.g. 0712345678</p>
        <p class="error" id="phone-error"></p>

        <button type="button" id="fees-toggle" aria-expanded="false" aria-controls="fees">Show fee details</button>
        <div id="fees" hidden>
          <p>Tuition TSh 150,000 per term. Uniform TSh 45,000.</p>
        </div>

        <p><button type="submit">Register</button></p>
      </form>

      <!-- Screen readers announce changes to a live region without moving focus -->
      <p id="status" role="status"></p>

      <dialog id="confirm" aria-labelledby="confirm-title">
        <h2 id="confirm-title">Register this student?</h2>
        <p id="confirm-text"></p>
        <form method="dialog">
          <button value="cancel">Cancel</button>
          <button value="ok">Yes, register</button>
        </form>
      </dialog>
    </main>
  </body>
</html>
Live preview in a sandboxed frame
app.jsJavaScript
const form = document.querySelector("#register");
const status = document.querySelector("#status");
const dialog = document.querySelector("#confirm");
const toggle = document.querySelector("#fees-toggle");

// A disclosure: a real <button> is focusable and works with Enter/Space for free
toggle.addEventListener("click", () => {
  const open = toggle.getAttribute("aria-expanded") === "true";
  toggle.setAttribute("aria-expanded", String(!open));
  document.getElementById(toggle.getAttribute("aria-controls")).hidden = open;
  toggle.textContent = open ? "Show fee details" : "Hide fee details";
});

function setError(input, message) {
  document.getElementById(`${input.id}-error`).textContent = message;
  if (message) input.setAttribute("aria-invalid", "true");
  else input.removeAttribute("aria-invalid");
}

function validate() {
  const name = form.elements.name;
  const phone = form.elements.phone;
  setError(name, name.value.trim().length < 2 ? "Enter the student's full name." : "");
  setError(phone, /^0[67]\d{8}$/.test(phone.value.trim()) ? "" : "Enter a 10-digit phone number starting with 06 or 07.");
  return form.querySelector('[aria-invalid="true"]');
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const firstInvalid = validate();
  if (firstInvalid) {
    firstInvalid.focus();                        // take keyboard and screen-reader users to the problem
    status.textContent = "Please fix the highlighted fields.";
    return;
  }
  status.textContent = "";
  document.querySelector("#confirm-text").textContent = `${form.elements.name.value.trim()}, parent phone ${form.elements.phone.value.trim()}`;
  dialog.showModal();                            // traps focus inside; Escape closes it
});

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "ok") {
    status.textContent = `${form.elements.name.value.trim()} registered.`;
    form.reset();
    form.elements.name.focus();
  } else {
    form.querySelector('button[type="submit"]').focus();   // return focus where the user was
  }
});

Key points

  • Semantic elements (button, label, main, headings) give keyboard and screen-reader support for free.
  • Tie errors to inputs with aria-describedby and aria-invalid, and move focus to the problem.
  • Announce changes with a live region, manage focus around dialogs and always keep visible focus styles.

Exercise

Build accessible tabs for Maths, Biology and Chemistry results: role="tablist", tab and tabpanel, aria-selected, and a roving tabindex so only the current tab is in the Tab order. Left/Right arrows move between tabs (wrapping around), and Home/End jump to the first and last.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Each tab is a real <button>, with role="tab" saying what kind of control it is. Only the selected tab has tabindex="0", so Tab moves from the tab list straight into the panel; the arrow keys move between tabs. aria-selected, aria-controls and aria-labelledby let a screen reader say "Biology, tab, 2 of 3, selected" and name each panel.

index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Results by subject</title>
    <style>
      body { font-family: system-ui, sans-serif; max-width: 32rem; margin: 1rem auto; padding: 0 1rem; }
      [role="tablist"] { display: flex; gap: 0.25rem; border-bottom: 2px solid #ccc; }
      [role="tab"] { padding: 0.5rem 1rem; border: 0; background: #eee; font-size: 1rem; }
      [role="tab"][aria-selected="true"] { background: #1a73e8; color: #fff; }
      [role="tabpanel"] { padding: 1rem 0; }
      :focus-visible { outline: 3px solid #f9a825; outline-offset: 2px; }
    </style>
    <script src="tabs.js" defer></script>
  </head>
  <body>
    <main>
      <h1>Form 4 results</h1>
      <div role="tablist" aria-label="Subjects">
        <button role="tab" id="tab-maths" aria-controls="panel-maths" aria-selected="true">Maths</button>
        <button role="tab" id="tab-biology" aria-controls="panel-biology" aria-selected="false" tabindex="-1">Biology</button>
        <button role="tab" id="tab-chemistry" aria-controls="panel-chemistry" aria-selected="false" tabindex="-1">Chemistry</button>
      </div>
      <section role="tabpanel" id="panel-maths" aria-labelledby="tab-maths" tabindex="0">
        <p>Average 70.8 · Highest: Ali Mohamed (95)</p>
      </section>
      <section role="tabpanel" id="panel-biology" aria-labelledby="tab-biology" tabindex="0" hidden>
        <p>Average 67.8 · Highest: Neema Kimaro (84)</p>
      </section>
      <section role="tabpanel" id="panel-chemistry" aria-labelledby="tab-chemistry" tabindex="0" hidden>
        <p>Average 61.0 · Highest: Amina Hassan (77)</p>
      </section>
    </main>
  </body>
</html>
Live preview in a sandboxed frame
tabs.jsJavaScript
const tabs = [...document.querySelectorAll('[role="tab"]')];

function select(tab) {
  for (const t of tabs) {
    const selected = t === tab;
    t.setAttribute("aria-selected", String(selected));
    t.tabIndex = selected ? 0 : -1;               // roving tabindex: only the current tab is in the Tab order
    document.getElementById(t.getAttribute("aria-controls")).hidden = !selected;
  }
  tab.focus();
}

for (const tab of tabs) {
  tab.addEventListener("click", () => select(tab));
  tab.addEventListener("keydown", (event) => {
    const i = tabs.indexOf(tab);
    const next = {
      ArrowRight: tabs[(i + 1) % tabs.length],
      ArrowLeft: tabs[(i - 1 + tabs.length) % tabs.length],
      Home: tabs[0],
      End: tabs[tabs.length - 1],
    }[event.key];
    if (next) {
      event.preventDefault();
      select(next);
    }
  });
}

Check your understanding

  1. Why use <button> instead of <div onclick>?

  2. How do you connect an error message to its input for screen readers?

  3. What does an element with role="status" do?

  4. After a modal dialog closes, where should focus go?

Ask AI