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">
:
<input>
and <ul>
popup list in my HTML markupThis 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:
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:
aria-live="polite"
on a visually hidden element inside the combobox containerInstead 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.
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.
Include the name of the combobox in the message:
It anchors the message to the current control.
aria-busy
on the list containerWhile 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.
When a screen reader user reaches the end of the list:
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>
Detect the user reaching the end of list (via keyboard or scroll).
Trigger loading: Set aria-busy="true"
on list and update live region:
document.getElementById('status-updates').textContent = 'Loading more items…';
On load complete:
aria-busy="false"
"More items loaded"
(and optionally clear after a delay)<li role="option">
elements"End of results"
via live region.Here's a minimal working example of an accessible asynchronous combobox with infinite scroll behavior and screen reader support.
<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>
.visually-hidden {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
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.
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>
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:
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:
aria-activedescendant
)keydown
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>
.option {
padding: 4px;
}
.option[aria-selected="true"] {
background-color: #0078d4;
color: white;
}
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:
<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">▼</button>
</div>
<ul id="listbox" role="listbox" tabindex="-1" hidden></ul>
<div id="status-region" class="visually-hidden" aria-live="polite"></div>
</div>
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();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeListbox();
}
});
document.addEventListener('click', (e) => {
if (!comboWrapper.contains(e.target)) {
closeListbox();
}
});
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.
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');
}
}
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">▼</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!
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.