Accessibility for DOM scripting

Manage focus deliberately, keep ARIA state in sync with the DOM, announce changes with live regions, and support the keyboard a custom widget implies.

Focus management

// open a modal: move focus in, remember where it came from
function openDialog(dialog, opener) {
  const previous = opener ?? document.activeElement;

  dialog.showModal();                 // native focus containment
  dialog.querySelector('[autofocus]')?.focus();

  dialog.addEventListener('close', () => {
    previous?.focus();                // always restore
  }, { once: true });
}

// make a non-focusable container focusable for one purpose
panel.tabIndex = -1;                  // focusable by script, not by Tab
panel.focus({ preventScroll: true });

// keep Tab inside a custom overlay
overlay.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;

  const focusable = [...overlay.querySelectorAll(
    'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
  )].filter((el) => el.offsetParent !== null);

  if (focusable.length === 0) return;

  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
});
  • tabindex="-1" makes an element focusable by script only; tabindex="0" adds it to the tab order - which is a change to the user's keyboard journey, not a styling detail.
  • Prefer <dialog> with showModal(): it brings focus containment, the top layer and Escape handling with no JavaScript.
  • Never move focus on every render. Focus is the user's position in the page and moving it silently is disorienting.
  • The inert attribute makes a whole subtree unfocusable and hidden from assistive technology, which is the correct way to disable the page behind a modal.
⚠️
A focus trap that cannot be left is worse than no trap. Always leave a keyboard exit - Escape, or a close button that is reachable - and handle the case where a custom widget has no focusable children at all, or Tab stops working entirely.

ARIA state that matches the DOM

// a disclosure: the ARIA state must follow the visible state
function togglePanel(button, panel) {
  const expanded = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', String(!expanded));
  panel.hidden = expanded;
}

// a custom listbox
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(isSelected));
list.setAttribute('aria-activedescendant', option.id);

// a tab set
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', String(active));
tab.setAttribute('aria-controls', panel.id);
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', tab.id);

// decorative versus meaningful
icon.setAttribute('aria-hidden', 'true');           // ignored by screen readers
button.setAttribute('aria-label', 'Close dialog');  // when there is no text
AttributeMeaningKeep in sync with
aria-expandedA region opened or closedVisibility of the region
aria-selectedWhich item is chosenThe selection state
aria-currentThe current item in a setThe active route or page
aria-hiddenIgnored by assistive techWhether the node is decorative
aria-disabledUnavailable but still focusableThe action's availability
aria-labelAn accessible nameThe visible text, if any

The first rule of ARIA is to use the right element instead. A <button> already has a role, keyboard activation and focus behaviour; a div with role="button" needs all three reimplemented and is usually missing one of them.

Live regions and keyboard interaction

<p id="status" role="status" aria-live="polite"></p>
<p id="errors" role="alert" aria-live="assertive"></p>
<div id="results" aria-live="polite" aria-atomic="false"></div>
// announce a change: the region must exist before you write to it
status.textContent = 'Saved 3 changes';
status.textContent = 'Saved 4 changes';   // a different string, or nothing is announced

// for a list, announce a summary rather than 500 items
results.setAttribute('aria-live', 'polite');
results.textContent = 'Showing 20 of 340 results';
  • polite waits for a pause; assertive interrupts. Use assertive only for errors the user must know about now.
  • The live region must be in the DOM before the change. Injecting the region and its text together is frequently missed by screen readers.
  • Setting the same text twice announces nothing - the change event is what triggers the announcement.
  • A custom widget needs the keyboard model its role implies: Enter and Space for a button, arrow keys for a listbox or menu, Home and End for a range.
// keyboard model for a custom listbox
list.addEventListener('keydown', (event) => {
  const index = options.indexOf(document.activeElement);

  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault();
      options[Math.min(index + 1, options.length - 1)].focus();
      break;
    case 'ArrowUp':
      event.preventDefault();
      options[Math.max(index - 1, 0)].focus();
      break;
    case 'Home':
      event.preventDefault();
      options[0].focus();
      break;
    case 'End':
      event.preventDefault();
      options[options.length - 1].focus();
      break;
  }
});

FAQ

How do I test that focus management works?
Drive it with the keyboard only: Tab through the page, open the overlay, press Escape, and check that focus returns to the control you came from. A quick automated check is to assert document.activeElement after each interaction, which is exactly what an assistive technology follows.
When should I use aria-live?
When content changes without the user moving focus and they need to know - a save confirmation, a results count, a validation error. Not for every state change; an over-announcing page is unusable, because the screen reader never stops talking.

Forms, validation and user input Geometry, scrolling and visibility

Last refreshed 2026-09-18.