Accessible AJAX: Designing Asynchronous Interactions Without Losing UX

AJAX (or any asynchronous call: fetch, XHR, libraries) makes interfaces fluid, but can become a nightmare for people using assistive technologies if we don’t manage states, focus, and announcements. This guide explains how to design asynchronous interactions that remain accessible without sacrificing UX.

Key principles

  • Progressive enhancement: everything must work even without JavaScript.
  • Announced states: communicate loading, success, and error via “live” regions.
  • Focus management: after updates, move focus in a predictable way.
  • Native semantics: use links, buttons, lists, headings. Avoid clicks on non-interactive elements.
  • Keyboard consistency: every action must be possible via keyboard.

Base markup (progressive enhancement)

Start from an interface that works even without JS. Links and forms send the request to the server; with JS enabled, we intercept the event and update the page in place.

<h2 id="results-title" tabindex="-1">Results</h2>

<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
<p id="error" role="alert" hidden></p>

<form action="/search" method="get">
  <label for="q">Search</label>
  <input id="q" name="q" type="search" required>
  <button type="submit">Search</button>
</form>

<ul id="results" aria-busy="false">
  <li>Initial content (server-rendered)</li>
</ul>

<button id="load-more">Load more results</button>

Notes: role="status" with aria-live="polite" announces messages without interrupting reading. role="alert" is for critical errors. tabindex="-1" makes the heading script-focusable, useful after an update.

Intercepting and enhancing with fetch

With JavaScript enabled, avoid full reloads while keeping updates accessible.

(() => {
  const form = document.querySelector('form[action="/search"]');
  const results = document.getElementById('results');
  const title = document.getElementById('results-title');
  const status = document.getElementById('status');
  const error = document.getElementById('error');
  const loadMore = document.getElementById('load-more');

  let nextPage = 2;
  let controller;

  function announce(msg) {
    status.textContent = msg;
  }

  function showError(msg) {
    error.hidden = false;
    error.textContent = msg;
  }

  function clearError() {
    error.hidden = true;
    error.textContent = '';
  }

  async function ajaxGet(url) {
    // Cancel previous requests to avoid race conditions
    if (controller) controller.abort();
    controller = new AbortController();

    results.setAttribute('aria-busy', 'true');
    announce('Loading…');

    try {
      const res = await fetch(url, { signal: controller.signal, headers: { 'Accept': 'application/json' }});
      if (!res.ok) throw new Error('Network error (' + res.status + ')');
      const data = await res.json();

      // Update the list non-destructively
      const frag = document.createDocumentFragment();
      data.items.forEach(item => {
        const li = document.createElement('li');
        li.innerHTML = item.html; // Ensure it’s sanitized server-side
        frag.appendChild(li);
      });
      results.appendChild(frag);

      clearError();
      announce('Loading complete: ' + data.items.length + ' items added.');

      // Move focus to the title to announce updated context
      title.focus();

      // Update history to reflect the state (optional but recommended)
      if (data.nextUrl) {
        history.replaceState({}, '', data.nextUrl);
      }
    } catch (e) {
      if (e.name !== 'AbortError') {
        showError('Unable to update results. Please try again later.');
        announce('Loading failed.');
      }
    } finally {
      results.setAttribute('aria-busy', 'false');
    }
  }

  form.addEventListener('submit', (ev) => {
    ev.preventDefault();
    const q = new URLSearchParams(new FormData(form));
    ajaxGet('/api/search?' + q.toString());
  });

  loadMore.addEventListener('click', () => {
    ajaxGet('/api/search?page=' + nextPage++);
  });
})();

Announcing quantity and position

Communicate how many items were added and, when useful, the current position (e.g., “item 21 of 40”). This helps screen reader users understand context.

function appendItems(listEl, items, totalCount) {
  const start = listEl.children.length + 1;
  const frag = document.createDocumentFragment();

  items.forEach((item, i) => {
    const li = document.createElement('li');
    const index = start + i;
    li.setAttribute('aria-posinset', String(index));
    li.setAttribute('aria-setsize', String(totalCount));
    li.innerHTML = item.html; // Sanitize server-side
    frag.appendChild(li);
  });

  listEl.appendChild(frag);
}

Loading indicators and states

  • aria-busy on the updated container signals that an update is in progress.
  • role="status" for non-intrusive messages; role="alert" for errors.
  • Do not remove existing content while loading; adding a textual indicator is enough.

Focus management

  • After a significant update, move focus to a heading or the first new item.
  • Use tabindex="-1" on the heading so it can be focused via script without entering the normal tab order.
  • Avoid unintentional “focus traps”: don’t block tabbing.

Keyboard handling

  • Use <button> for actions and <a> for navigation. Avoid clicks on non-interactive elements.
  • If you create custom components, support Enter and Space, and manage aria-pressed or appropriate roles.

URL and history

Keeping the URL consistent with the state helps with returning and bookmarking, even for assistive technology users.

// Update the URL when the filter/search changes
const params = new URLSearchParams({ q: 'accessibility', page: '2' });
history.pushState({ q: 'accessibility', page: 2 }, '', '/search?' + params.toString());

// Restore state when the user uses Back/Forward
window.addEventListener('popstate', (e) => {
  // Rehydrate UI and redo the AJAX request consistent with the state
});

Accessible errors

Errors must be perceivable and re-announced. Avoid relying solely on color or non-voiced toast messages.

<p id="error" role="alert" hidden></p>
function showError(msg) {
  const error = document.getElementById('error');
  error.hidden = false;
  error.textContent = msg; // Screen reader will announce it
}

Minimal complete example

<h2 id="results-title" tabindex="-1">Articles</h2>
<p id="status" role="status" aria-live="polite" aria-atomic="true"></p>
<p id="error" role="alert" hidden></p>

<form action="/articles" method="get">
<label for="topic">Topic</label>
<input id="topic" name="topic" type="search" required>
<button type="submit">Filter</button>
</form>

<ul id="results" aria-busy="false">
<li>Article 1 (server)</li>
</ul>

<button id="load-more">Load more</button>

<noscript>Dynamic search requires JavaScript. You can use the form above for a full server-side search.</noscript>

Quick checklist

  1. Does it work without JS? (traditional forms/links)
  2. Announcements: role="status" or role="alert" present and used.
  3. aria-busy on the updated container.
  4. Focus moved back to heading or new content.
  5. Actions use native, keyboard-usable elements.
  6. URL updated when relevant state changes.

By following these tips, AJAX improves the experience for everyone without excluding anyone, keeping the interface fast, perceivable, and predictable.

Back to top