htmlcomboboxaccessibility

Providing "loading" status in a combobox to assistive technologies


I am implementing a combobox inspired by https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/. Now, the combobox should also have the feature to load items asynchronously (typically via REST call). To indicate the loading state visually I display a loading spinner. But I don't know how to present the loading state to assistive technologies correctly.

My approach with <div role="status">:

This is announced by NVDA as expected. But: I can still move the reading focus out of the combobox and to the status which announces "items loaded". I think this could be confusing for the screen reader user as it has not enough context (e.g. NVDA does not announce that this is a status) and makes no sense outside of the combobox. As far as I read, it is technically not possible to hide the status from AT here but keep the announcement on change of status.

How do you solve this problem?

When does the combobox load items:

Example screenshots:

combobox without loading spinner

combobox with loading spinner

combobox with opened item list


Solution

  • You've done a great job incorporating accessibility considerations into your combobox—bonus points for observing actual screen reader behavior and going beyond the WAI-ARIA spec. That level of care makes a real difference.

    You're absolutely right that <div role="status"> gets announced by NVDA as a live region but then becomes part of the tab order for reading mode, which can be confusing when it lacks semantic context like “status.” Unfortunately, there isn't a perfect ARIA magic bullet here—but here are a few ideas that strike a good balance:

    1. Use aria-live="polite" on a visually hidden element inside the combobox container

    Instead of using role="status"—which acts as an implicit live region and is navigable in some screen readers—you can:

    <div class="visually-hidden" aria-live="polite" id="loading-status"></div>
    

    This:

    Set "loading items" or "items loaded" as text content dynamically. This hidden region should be within the combobox context (same DOM subtree) so announcements make more contextual sense.

    2. Avoid persistent status updates

    Clear the live region immediately after announcing:

    statusElement.textContent = 'Loading items…';
    setTimeout(() => { statusElement.textContent = ''; }, 1000);
    

    This keeps it from being navigable after it’s no longer relevant.

    3. Consider contextualizing the message

    Include the name of the combobox in the message:

    It anchors the message to the current control.

    4. Add aria-busy on the list container

    While not announced by most screen readers on its own, it can signal that dynamic content is coming:

    <ul role="listbox" aria-busy="true">
    

    Set this to true during loading and false once loading is complete. It doesn't solve the announcement issue, but it provides better semantics.


    If you’re using a component-based framework (React, Vue, etc.), you can wrap this logic into reusable hooks/components for consistency.

    Let’s build on your asynchronous combobox with progressive loading (aka infinite scroll) in a way that keeps it accessible and user-friendly.

    🌐 UX Behaviors

    When a screen reader user reaches the end of the list:

    🧱 Key Elements

    Here’s a sketch of the architecture:

    <div role="combobox" aria-expanded="true" aria-owns="listbox-id">
      <input aria-autocomplete="list" ... />
    
      <ul id="listbox-id" role="listbox" aria-busy="true">
        <li role="option">...</li>
        <!-- Loading indicator appears as last item -->
        <li role="option" class="visually-hidden" aria-live="polite" id="more-status">
          Loading more items...
        </li>
      </ul>
    
      <!-- Visually hidden live region for updates -->
      <div aria-live="polite" class="visually-hidden" id="status-updates"></div>
    </div>
    

    🔄 Script Behavior

    1. Detect the user reaching the end of list (via keyboard or scroll).

    2. Trigger loading: Set aria-busy="true" on list and update live region:

      document.getElementById('status-updates').textContent = 'Loading more items…';
      
    3. On load complete:

      • Set aria-busy="false"
      • Update live region to "More items loaded" (and optionally clear after a delay)
      • Append new <li role="option"> elements

    📏 Best Practices

    Here's a minimal working example of an accessible asynchronous combobox with infinite scroll behavior and screen reader support.

    🧩 HTML Structure

    <div class="combobox-container">
      <label for="combo">Search countries</label>
      <div role="combobox" aria-expanded="true" aria-owns="listbox" aria-haspopup="listbox">
        <input id="combo" type="text" aria-autocomplete="list" aria-controls="listbox" aria-activedescendant="" />
      </div>
    
      <ul id="listbox" role="listbox" aria-busy="false" tabindex="-1">
        <!-- List items are injected here -->
      </ul>
    
      <!-- Live region for status updates (invisible to sighted users) -->
      <div id="status-region" class="visually-hidden" aria-live="polite"></div>
    </div>
    

    👓 CSS (Visually Hidden Class)

    .visually-hidden {
      position: absolute;
      left: -9999px;
      width: 1px;
      height: 1px;
      overflow: hidden;
    }
    

    ⚙️ JavaScript (with Simulated Async Fetch)

    const input = document.getElementById('combo');
    const listbox = document.getElementById('listbox');
    const status = document.getElementById('status-region');
    
    let allItems = []; // full dataset
    let visibleItems = []; // currently shown
    let loading = false;
    let offset = 0;
    const PAGE_SIZE = 20;
    
    function simulateFetch(start, count = PAGE_SIZE) {
      return new Promise(resolve => {
        setTimeout(() => {
          const newItems = allItems.slice(start, start + count);
          resolve(newItems);
        }, 1000); // simulate network latency
      });
    }
    
    function renderList(items) {
      listbox.innerHTML = '';
      for (const item of items) {
        const li = document.createElement('li');
        li.role = 'option';
        li.textContent = item;
        listbox.appendChild(li);
      }
    }
    
    async function loadMore() {
      if (loading) return;
      loading = true;
      listbox.setAttribute('aria-busy', 'true');
      status.textContent = 'Loading more items...';
    
      const newItems = await simulateFetch(offset);
      offset += newItems.length;
      visibleItems.push(...newItems);
      renderList(visibleItems);
    
      status.textContent = `${newItems.length} items loaded.`;
      setTimeout(() => status.textContent = '', 1000);
    
      listbox.setAttribute('aria-busy', 'false');
      loading = false;
    }
    
    // Simulate a large dataset
    for (let i = 1; i <= 200; i++) {
      allItems.push(`Country ${i}`);
    }
    
    // Initial load
    loadMore();
    
    listbox.addEventListener('scroll', () => {
      const nearBottom = listbox.scrollTop + listbox.clientHeight >= listbox.scrollHeight - 20;
      if (nearBottom) {
        loadMore();
      }
    });
    

    This should give you a solid, accessible foundation. From here, you can enrich the experience with keyboard navigation, filtering, and debounce handling.

    An example using the REST Countries API, which is a great public endpoint for fetching country names. But you can plug in your own REST service easily.


    🧩 Updated HTML Structure

    Same as before—just make sure you’ve got a live region and a list container:

    <input id="combo" type="text" aria-autocomplete="list" aria-controls="listbox" />
    <ul id="listbox" role="listbox" aria-busy="false"></ul>
    <div id="status-region" class="visually-hidden" aria-live="polite"></div>
    

    ⚙️ JavaScript With Real Fetch

    const input = document.getElementById('combo');
    const listbox = document.getElementById('listbox');
    const status = document.getElementById('status-region');
    
    let loading = false;
    
    async function fetchCountries(query) {
      const url = `https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name`;
      const response = await fetch(url);
      if (!response.ok) throw new Error('Fetch failed');
      const data = await response.json();
      return data.map(country => country.name.common);
    }
    
    function renderOptions(countries) {
      listbox.innerHTML = '';
      countries.forEach(name => {
        const li = document.createElement('li');
        li.role = 'option';
        li.textContent = name;
        listbox.appendChild(li);
      });
    }
    
    async function loadCountries() {
      const query = input.value.trim();
      if (!query) {
        renderOptions([]);
        return;
      }
    
      loading = true;
      listbox.setAttribute('aria-busy', 'true');
      status.textContent = 'Loading items…';
    
      try {
        const results = await fetchCountries(query);
        renderOptions(results);
        status.textContent = `${results.length} item(s) loaded`;
      } catch (err) {
        status.textContent = 'Error loading items';
      } finally {
        setTimeout(() => status.textContent = '', 1500);
        listbox.setAttribute('aria-busy', 'false');
        loading = false;
      }
    }
    
    input.addEventListener('input', () => {
      if (!loading) loadCountries();
    });
    

    This setup dynamically fetches countries based on input and announces the loading status to screen readers—accessible and functional!

    If you want to take it further with pagination or debouncing to avoid overloading the API while typing:


    🧠 Conceptual Upgrades:

    1. Debouncing: Avoid API calls while the user is typing rapidly. This improves performance and prevents rate limits.
    2. Pagination: As the user scrolls or navigates to the end of the list, fetch the next “page” of data.

    ⚙️ Updated JavaScript with Debounce + Simulated Paging

    const input = document.getElementById('combo');
    const listbox = document.getElementById('listbox');
    const status = document.getElementById('status-region');
    
    let currentQuery = '';
    let currentPage = 1;
    let loading = false;
    const PAGE_SIZE = 10;
    let debounceTimer;
    
    function debounce(fn, delay) {
      clearTimeout(debounceTimer);
      debounceTimer = setTimeout(fn, delay);
    }
    
    async function fetchCountries(query, page = 1) {
      const url = `https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name`;
      const response = await fetch(url);
      if (!response.ok) throw new Error('Fetch failed');
      const data = await response.json();
      return data
        .map(country => country.name.common)
        .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
    }
    
    function appendOptions(countries) {
      countries.forEach(name => {
        const li = document.createElement('li');
        li.role = 'option';
        li.textContent = name;
        listbox.appendChild(li);
      });
    }
    
    async function loadCountries(reset = false) {
      if (loading) return;
      loading = true;
      listbox.setAttribute('aria-busy', 'true');
      status.textContent = 'Loading items…';
    
      if (reset) {
        listbox.innerHTML = '';
        currentPage = 1;
      }
    
      try {
        const results = await fetchCountries(currentQuery, currentPage);
        appendOptions(results);
        status.textContent = `${results.length} item(s) loaded`;
        currentPage++;
      } catch (err) {
        status.textContent = 'Error loading items';
      } finally {
        setTimeout(() => (status.textContent = ''), 1500);
        listbox.setAttribute('aria-busy', 'false');
        loading = false;
      }
    }
    
    input.addEventListener('input', () => {
      currentQuery = input.value.trim();
      debounce(() => loadCountries(true), 300);
    });
    
    // Optional: Infinite scroll-style loading (if listbox is scrollable)
    listbox.addEventListener('scroll', () => {
      const nearBottom = listbox.scrollTop + listbox.clientHeight >= listbox.scrollHeight - 20;
      if (nearBottom) {
        loadCountries(false);
      }
    });
    

    You now have:

    If you would you like to enhance this further with keyboard arrow key navigation, highlighting active options, or integrating loading spinners visually:

    We’ll now add:

    1. Arrow key navigation between results
    2. Active descendant tracking (aria-activedescendant)
    3. Focus management using keydown
    4. Visual focus indicator and highlighting

    🎯 Enhancements to HTML

    Add a visual indicator (via CSS) and update the input with aria-activedescendant:

    <input id="combo" aria-autocomplete="list" aria-controls="listbox" aria-activedescendant="" />
    

    Each <li> should also have a unique id so the input can point to the active one:

    <li role="option" id="option-0" class="option">Country name</li>
    

    🎨 CSS for Highlighted Option

    .option {
      padding: 4px;
    }
    
    .option[aria-selected="true"] {
      background-color: #0078d4;
      color: white;
    }
    

    🧠 Updated JavaScript: Navigation + Highlighting

    We’ll track the index of the highlighted result and wire it to keyboard interaction:

    let activeIndex = -1;
    
    function highlightOption(index) {
      const options = listbox.querySelectorAll('[role="option"]');
      options.forEach((opt, i) => {
        opt.setAttribute('aria-selected', i === index);
      });
    
      if (options[index]) {
        input.setAttribute('aria-activedescendant', options[index].id);
        options[index].scrollIntoView({ block: 'nearest' });
      } else {
        input.removeAttribute('aria-activedescendant');
      }
    }
    
    input.addEventListener('keydown', (event) => {
      const options = listbox.querySelectorAll('[role="option"]');
      if (!options.length) return;
    
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        activeIndex = (activeIndex + 1) % options.length;
        highlightOption(activeIndex);
      } else if (event.key === 'ArrowUp') {
        event.preventDefault();
        activeIndex = (activeIndex - 1 + options.length) % options.length;
        highlightOption(activeIndex);
      } else if (event.key === 'Enter') {
        if (options[activeIndex]) {
          input.value = options[activeIndex].textContent;
          listbox.innerHTML = ''; // collapse list
        }
      }
    });
    
    function appendOptions(countries) {
      countries.forEach((name, i) => {
        const li = document.createElement('li');
        li.role = 'option';
        li.className = 'option';
        li.textContent = name;
        li.id = `option-${i + listbox.children.length}`;
        listbox.appendChild(li);
      });
    }
    

    You now have:

    If you want, we could take this even further with mouseover support, focus trapping, or a closed/open combobox toggle like in WAI-ARIA best practices, like so:


    🧠 Final Features We'll Add:

    1. Combobox open/close state with toggle button
    2. Focus trapping so navigation stays logical
    3. Escape key closes the list
    4. Clicking outside closes the list
    5. Mouse hover highlights options

    ✨ HTML Additions

    <div class="combobox-container" role="combobox" aria-expanded="false" aria-haspopup="listbox" aria-owns="listbox">
      <label for="combo">Search countries</label>
      <div class="input-wrapper">
        <input id="combo" type="text" aria-autocomplete="list" aria-controls="listbox" aria-activedescendant="" />
        <button id="toggle" aria-label="Toggle menu">&#9660;</button>
      </div>
      <ul id="listbox" role="listbox" tabindex="-1" hidden></ul>
      <div id="status-region" class="visually-hidden" aria-live="polite"></div>
    </div>
    

    🧠 New Behaviors in JavaScript

    Toggle list open/closed

    const comboWrapper = document.querySelector('.combobox-container');
    const toggleBtn = document.getElementById('toggle');
    
    function openListbox() {
      listbox.hidden = false;
      comboWrapper.setAttribute('aria-expanded', 'true');
    }
    
    function closeListbox() {
      listbox.hidden = true;
      comboWrapper.setAttribute('aria-expanded', 'false');
      input.removeAttribute('aria-activedescendant');
      activeIndex = -1;
    }
    
    toggleBtn.addEventListener('click', () => {
      if (listbox.hidden) {
        openListbox();
        input.focus();
        loadCountries(true); // optional
      } else {
        closeListbox();
      }
    });
    

    Escape key closes dropdown

    input.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        closeListbox();
      }
    });
    

    Click outside to close

    document.addEventListener('click', (e) => {
      if (!comboWrapper.contains(e.target)) {
        closeListbox();
      }
    });
    

    Mouse hover highlights items

    Update appendOptions:

    function appendOptions(countries) {
      countries.forEach((name, i) => {
        const li = document.createElement('li');
        li.role = 'option';
        li.className = 'option';
        li.textContent = name;
        li.id = `option-${i + listbox.children.length}`;
    
        li.addEventListener('mouseenter', () => {
          activeIndex = i;
          highlightOption(i);
        });
    
        li.addEventListener('click', () => {
          input.value = name;
          closeListbox();
        });
    
        listbox.appendChild(li);
      });
    }
    

    Now you've got:

    Let’s box this beauty up into a clean, reusable vanilla JavaScript class so you can drop it into any project and have a full-featured, accessible async combobox ready to go.


    🎁 The ComboBox Component (ES6 Class)

    class AsyncComboBox {
      constructor({ input, listbox, toggle, status, fetcher, pageSize = 10, delay = 300 }) {
        this.input = input;
        this.listbox = listbox;
        this.toggle = toggle;
        this.status = status;
        this.fetcher = fetcher;
        this.pageSize = pageSize;
        this.delay = delay;
    
        this.query = '';
        this.page = 1;
        this.loading = false;
        this.activeIndex = -1;
        this.debounceTimer = null;
    
        this.init();
      }
    
      init() {
        this.input.addEventListener('input', () => this.debounce(() => this.search(true), this.delay));
        this.input.addEventListener('keydown', (e) => this.onKeyDown(e));
        this.listbox.addEventListener('scroll', () => this.onScroll());
        this.toggle?.addEventListener('click', () => this.toggleListbox());
        document.addEventListener('click', (e) => {
          if (!this.input.closest('.combobox-container').contains(e.target)) {
            this.closeListbox();
          }
        });
      }
    
      async search(reset = false) {
        if (this.loading) return;
        if (reset) {
          this.page = 1;
          this.activeIndex = -1;
          this.listbox.innerHTML = '';
          this.query = this.input.value.trim();
        }
    
        this.loading = true;
        this.setStatus('Loading items...');
        this.setBusy(true);
    
        try {
          const results = await this.fetcher(this.query, this.page, this.pageSize);
          results.forEach((name, i) => {
            const li = document.createElement('li');
            li.role = 'option';
            li.textContent = name;
            li.id = `option-${this.listbox.children.length}`;
            li.className = 'option';
            li.addEventListener('mouseenter', () => {
              this.activeIndex = i;
              this.highlight(i);
            });
            li.addEventListener('click', () => {
              this.input.value = name;
              this.closeListbox();
            });
            this.listbox.appendChild(li);
          });
    
          this.setStatus(`${results.length} item(s) loaded`);
          this.page++;
        } catch (err) {
          this.setStatus('Error loading items');
        } finally {
          this.setBusy(false);
          setTimeout(() => this.setStatus(''), 1500);
          this.loading = false;
        }
    
        this.openListbox();
      }
    
      onKeyDown(e) {
        const options = this.listbox.querySelectorAll('[role="option"]');
        if (!options.length) return;
    
        if (e.key === 'ArrowDown') {
          e.preventDefault();
          this.activeIndex = (this.activeIndex + 1) % options.length;
          this.highlight(this.activeIndex);
        } else if (e.key === 'ArrowUp') {
          e.preventDefault();
          this.activeIndex = (this.activeIndex - 1 + options.length) % options.length;
          this.highlight(this.activeIndex);
        } else if (e.key === 'Enter') {
          const opt = options[this.activeIndex];
          if (opt) {
            this.input.value = opt.textContent;
            this.closeListbox();
          }
        } else if (e.key === 'Escape') {
          this.closeListbox();
        }
      }
    
      onScroll() {
        const nearBottom = this.listbox.scrollTop + this.listbox.clientHeight >= this.listbox.scrollHeight - 20;
        if (nearBottom) this.search(false);
      }
    
      toggleListbox() {
        if (this.listbox.hidden) {
          this.openListbox();
          this.search(true);
        } else {
          this.closeListbox();
        }
        this.input.focus();
      }
    
      openListbox() {
        this.listbox.hidden = false;
        this.input.parentNode.setAttribute('aria-expanded', 'true');
      }
    
      closeListbox() {
        this.listbox.hidden = true;
        this.input.parentNode.setAttribute('aria-expanded', 'false');
        this.input.removeAttribute('aria-activedescendant');
        this.activeIndex = -1;
      }
    
      highlight(index) {
        const options = this.listbox.querySelectorAll('[role="option"]');
        options.forEach((opt, i) => {
          opt.setAttribute('aria-selected', i === index);
        });
        const opt = options[index];
        if (opt) {
          this.input.setAttribute('aria-activedescendant', opt.id);
          opt.scrollIntoView({ block: 'nearest' });
        }
      }
    
      debounce(fn, delay) {
        clearTimeout(this.debounceTimer);
        this.debounceTimer = setTimeout(fn, delay);
      }
    
      setStatus(msg) {
        this.status.textContent = msg;
      }
    
      setBusy(isBusy) {
        this.listbox.setAttribute('aria-busy', isBusy ? 'true' : 'false');
      }
    }
    

    🪄 Usage Example

    const combo = new AsyncComboBox({
      input: document.getElementById('combo'),
      listbox: document.getElementById('listbox'),
      toggle: document.getElementById('toggle'),
      status: document.getElementById('status-region'),
      pageSize: 10,
      fetcher: async (query, page, size) => {
        const res = await fetch(`https://restcountries.com/v3.1/name/${encodeURIComponent(query)}?fields=name`);
        if (!res.ok) throw new Error('Failed to fetch');
        const data = await res.json();
        return data.map(c => c.name.common).slice((page - 1) * size, page * size);
      }
    });
    

    Here's the full working scaffold:


    📄 index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
      <title>Async Accessible Combobox</title>
      <link rel="stylesheet" href="styles.css" />
    </head>
    <body>
      <div class="combobox-container" role="combobox" aria-expanded="false" aria-haspopup="listbox" aria-owns="listbox">
        <label for="combo">Search countries</label>
        <div class="input-wrapper">
          <input id="combo" type="text" aria-autocomplete="list" aria-controls="listbox" aria-activedescendant="" />
          <button id="toggle" aria-label="Toggle menu">&#9660;</button>
        </div>
        <ul id="listbox" role="listbox" tabindex="-1" hidden></ul>
        <div id="status-region" class="visually-hidden" aria-live="polite"></div>
      </div>
    
      <script src="combobox.js"></script>
    </body>
    </html>
    

    🎨 styles.css

    body {
      font-family: sans-serif;
      padding: 2rem;
    }
    
    .combobox-container {
      position: relative;
      width: 300px;
    }
    
    .input-wrapper {
      display: flex;
      align-items: center;
    }
    
    #combo {
      width: 100%;
      padding: 0.5rem;
      font-size: 1rem;
    }
    
    #toggle {
      padding: 0.5rem;
      font-size: 1rem;
      cursor: pointer;
    }
    
    #listbox {
      border: 1px solid #ccc;
      max-height: 200px;
      overflow-y: auto;
      margin-top: 4px;
      padding: 0;
      list-style: none;
      background: white;
    }
    
    .option {
      padding: 4px 8px;
    }
    
    .option[aria-selected="true"] {
      background-color: #0078d4;
      color: white;
    }
    
    .visually-hidden {
      position: absolute;
      left: -9999px;
      width: 1px;
      height: 1px;
      overflow: hidden;
    }
    

    ⚙️ combobox.js

    Paste the full JavaScript component I gave you previously into this file, and you’ll be ready to rock!


    Edit: Answer to comment:

    Yes, clearing the live region after an announcement is a widely used best practice in accessibility circles. It helps ensure:

    People often pair it with either a setTimeout(...) or clever string rotation to force announcements (e.g. toggling between 'Loading' and 'Loading.').

    Since you’re wrapping this in an Angular component: feel free to bind the live region to a reactive value in your component class and just emit strings when status changes. Bonus points if you include ChangeDetectorRef.detectChanges() immediately after updating the status to ensure timely DOM updates for screen reader queues.

    You're clearly thinking deeply about UX for everyone—it’s inspiring.