# Infinite scroll that moves the footer away as Tab nears it

Source: https://easeweb.dev/learn/infinite-scroll
Topics: Complex widgets > Infinite scroll lets people reach the footer and announces new items (WCAG 2.1.1, 2.4.3, 4.1.3)
The fix: Either load the next items only when a Load more button after the list is pressed, move focus to the first new item and write the new count into a role="status" line; or, where the list must keep loading as it scrolls, make it a role="feed" whose Ctrl + End moves focus past it, say so on the page, and put the loading message and the count in the same kind of status line. Either way, add the new items after the old ones rather than rebuilding the list.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://www.w3.org/WAI/ARIA/apg/patterns/feed/ https://www.w3.org/WAI/ARIA/apg/patterns/feed/examples/feed/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/feed_role

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's category page lists its 48 mugs, twelve at a time. There are no page links and no button: as the end of the list comes into view, a "Loading more mugs…" line shows under the last row, and a moment later twelve more mugs appear and the line goes. For someone scrolling with a mouse or a finger, the list just keeps going, which is what the design is for. Under it, the footer holds Contact us and Returns.

Put the mouse aside and press Tab to get to Contact us. Focus moves from mug to mug. Each time it reaches the last rows, the page scrolls to show the focused mug, the end of the list comes within reach, and twelve more mugs are added between focus and the footer. The next Tab goes into them. Contact us comes only once all 48 mugs have loaded and Tab has passed every one of them; on a list with no end, it never comes. Meanwhile a screen reader says nothing: the "Loading more mugs…" line comes and goes in a plain `div`, and the new mugs arrive in silence.

A second version of the page adds each batch with `list.innerHTML += html`. That line rebuilds every mug in the list, so the mug that had focus is removed along with the rest, and focus is left on the page itself. In Chrome, the next Tab goes back to the first mug. Every twelve mugs, a keyboard user is sent back to the start of the list, and a screen reader user loses the place they were reading.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything the page offers can be done from the keyboard. Contact us and Returns are ordinary links, but Tab reaches them only when there is nothing left to load, because the list grows ahead of focus each time it nears them. On this page that means passing all 48 mugs; on a list with no end, the footer's links can't be reached from the keyboard at all. A screen reader's landmark keys can jump straight to the footer, since it is in the page all along; Tab has no such key.

Success criterion 2.4.3 Focus Order asks that focus moves through the page in an order that keeps its meaning and its use. When the list is rebuilt, the focused mug no longer exists, and the browser has nowhere to keep focus but the page itself. Where the next Tab goes is then the browser's choice, not the page's: in Chrome it is the first mug. The order of the mugs hasn't changed, but the person's place in it is lost every time more load.

Success criterion 4.1.3 Status Messages asks that a message about the page's state, such as that it is waiting for more, reaches a screen reader without taking focus. "Loading more mugs…" is such a message, shown in a plain `div` with no role, so a screen reader is never told about it. That is failure F103. The mugs themselves are content, not a status message, and 4.1.3 doesn't ask for each to be announced; a count of how many are shown is a status message, when the page shows one.

## Who is affected

Infinite scroll is common on shop category pages, search results, news sites and social feeds, especially in phone layouts, and it often comes from one shared list component, so the failure repeats on every list the site has. The footer it moves away often holds the links people come looking for when something goes wrong: contact, delivery, returns, the accessibility statement.

## What automation and AI miss

Every mug is a link with a name, and the footer is a footer. Nothing in the markup is invalid, so axe has nothing to report: whether the footer can be reached, and whether focus survives a load, only show up while the page is used.

EaseWeb's audit scrolls the page to its end and counts the links ahead of the footer, then focuses the last of them, as Tab would leave it. A list that grows by itself is sent to review, since only a person can tell whether the list ends soon, or whether a skip key exists. A focused link that the load removes, leaving focus on the page, is reported as a 2.4.3 failure, because code alone proves it. A loading line on a page with no live region is sent to review too. No check can tell whether what is announced makes sense to a listener.

An AI fix often reaches for ARIA here. Adding `aria-live="polite"` to the whole list makes a screen reader read out all twelve new mug names each time, which is noise, not a count. Adding `role="feed"` alone changes nothing for someone using Tab: the keys that move past a feed come from the page's own script.

## Before: mugs that load as the page scrolls

```html
<main>
  <h1>Mugs</h1>
  <ul class="products" data-total="48">
    <li>
      <a href="/mugs/enamel-white">
        <img src="/images/enamel-white.jpg" alt="">
        Enamel mug, white
      </a>
    </li>
    …
  </ul>
  <div class="loading" hidden>Loading more mugs…</div>
  <div class="sentinel"></div>
</main>
<footer>
  <a href="/contact">Contact us</a>
  <a href="/returns">Returns</a>
</footer>
```

```js
const list = document.querySelector('.products');
const loading = document.querySelector('.loading');
const sentinel = document.querySelector('.sentinel');
const total = Number(list.dataset.total);
let page = 1;
let busy = false;
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || busy) return;
  busy = true;
  loading.hidden = false;
  const response = await fetch(`/mugs?page=${page + 1}&partial=cards`);
  list.insertAdjacentHTML('beforeend', await response.text());
  page += 1;
  loading.hidden = true;
  busy = false;
  if (list.children.length >= total) observer.disconnect();
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);
```

The empty `div` after the list is watched, and once it comes within 200 pixels of the window, twelve more mugs are fetched and added ahead of the footer. Focusing one of the last mugs scrolls the page far enough to trigger it, so Tab always finds more mugs before the footer. The loading line is shown and hidden in a plain `div`, which a screen reader is never told about.

## Before: the same list, rebuilt with each batch

```html
<main>
  <h1>Mugs</h1>
  <ul class="products" data-total="48">
    <li>
      <a href="/mugs/enamel-white">
        <img src="/images/enamel-white.jpg" alt="">
        Enamel mug, white
      </a>
    </li>
    …
  </ul>
  <div class="loading" hidden>Loading more mugs…</div>
  <div class="sentinel"></div>
</main>
<footer>
  <a href="/contact">Contact us</a>
  <a href="/returns">Returns</a>
</footer>
```

```js
const list = document.querySelector('.products');
const loading = document.querySelector('.loading');
const sentinel = document.querySelector('.sentinel');
const total = Number(list.dataset.total);
let page = 1;
let busy = false;
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || busy) return;
  busy = true;
  loading.hidden = false;
  const response = await fetch(`/mugs?page=${page + 1}&partial=cards`);
  list.innerHTML += await response.text();
  page += 1;
  loading.hidden = true;
  busy = false;
  if (list.children.length >= total) observer.disconnect();
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);
```

One line differs. `innerHTML +=` reads the list's markup as text, adds the new mugs to it, and builds every mug again from the result. The mug that had focus is among those thrown away, so focus falls to the page, and the next Tab starts from wherever the browser decides. The footer and the silent loading line fail as in the first version.

## After: a Load more button and a status line

```html
<main>
  <h1>Mugs</h1>
  <ul class="products" data-total="48">
    <li>
      <a href="/mugs/enamel-white">
        <img src="/images/enamel-white.jpg" alt="">
        Enamel mug, white
      </a>
    </li>
    …
  </ul>
  <p class="count" role="status">Showing 12 of 48 mugs</p>
  <button type="button" class="load-more">Show 12 more</button>
</main>
<footer>
  <a href="/contact">Contact us</a>
  <a href="/returns">Returns</a>
</footer>
```

```js
const list = document.querySelector('.products');
const count = document.querySelector('.count');
const button = document.querySelector('.load-more');
const total = Number(list.dataset.total);
let page = 1;
let busy = false;
button.addEventListener('click', async () => {
  if (busy) return;
  busy = true;
  count.textContent = 'Loading more mugs…';
  const response = await fetch(`/mugs?page=${page + 1}&partial=cards`);
  const first = list.children.length;
  list.insertAdjacentHTML('beforeend', await response.text());
  page += 1;
  // Focus goes to the first new mug; the count is written after it.
  list.children[first].querySelector('a').focus();
  count.textContent = `Showing ${list.children.length} of ${total} mugs`;
  button.hidden = list.children.length >= total;
  busy = false;
});
```

Nothing loads until the button is pressed, so the footer stays where it is: one Tab after the last mug and the button. The button is a native one, so Enter and Space press it with no key handling to write. The new mugs are added after the old ones, so nothing that had focus is removed, and focus moves to the first new mug, where the person wanted to go on reading. The status line is on the page from the start, so a screen reader announces "Loading more mugs…" and then "Showing 24 of 48 mugs" as they are written, without focus going there. It sits beside the button that changes it, where people look for it. When every mug is shown, the button goes and the line says "Showing 48 of 48 mugs".

## After: a feed that keeps loading as it scrolls

```html
<main>
  <h1 id="mugs-title">Mugs</h1>
  <p class="feed-keys">In the list, Page Down and Page Up move from mug to mug, and Ctrl+End skips past it.</p>
  <div class="products" role="feed" aria-labelledby="mugs-title" aria-busy="false" data-total="48">
    <article aria-posinset="1" aria-setsize="48" aria-labelledby="mug-1" tabindex="-1">
      <a href="/mugs/enamel-white" id="mug-1">
        <img src="/images/enamel-white.jpg" alt="">
        Enamel mug, white
      </a>
    </article>
    …
  </div>
  <p class="count" role="status">Showing 12 of 48 mugs</p>
  <div class="sentinel"></div>
</main>
<footer>
  <a href="/contact">Contact us</a>
  <a href="/returns">Returns</a>
</footer>
```

```js
const feed = document.querySelector('[role="feed"]');
const count = document.querySelector('.count');
const sentinel = document.querySelector('.sentinel');
const total = Number(feed.dataset.total);
let page = 1;
let busy = false;
// Nothing loads while focus is past the feed, so the footer stays where Ctrl+End put it.
const past = (el) => !feed.contains(el) && feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING;
const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || busy || past(document.activeElement)) return;
  busy = true;
  feed.setAttribute('aria-busy', 'true');
  count.textContent = 'Loading more mugs…';
  const response = await fetch(`/mugs?page=${page + 1}&partial=articles`);
  feed.insertAdjacentHTML('beforeend', await response.text());
  page += 1;
  feed.setAttribute('aria-busy', 'false');
  count.textContent = `Showing ${feed.children.length} of ${total} mugs`;
  busy = false;
  if (feed.children.length >= total) observer.disconnect();
}, { rootMargin: '0px 0px 200px 0px' });
observer.observe(sentinel);

// The feed's keys, as the ARIA Authoring Practices suggest.
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const outside = (el) => !feed.contains(el) && !el.contains(feed);
feed.addEventListener('keydown', (event) => {
  const article = event.target.closest('article');
  if (!article) return;
  const all = [...document.querySelectorAll(FOCUSABLE)].filter(outside);
  if (event.key === 'PageDown' && article.nextElementSibling) {
    article.nextElementSibling.focus();
  } else if (event.key === 'PageUp' && article.previousElementSibling) {
    article.previousElementSibling.focus();
  } else if (event.ctrlKey && event.key === 'End') {
    all.find((el) => feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)?.focus();
  } else if (event.ctrlKey && event.key === 'Home') {
    all.findLast((el) => feed.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)?.focus();
  } else {
    return;
  }
  event.preventDefault();
});
```

The list keeps loading as it scrolls, but it is now a feed, the ARIA pattern for exactly this: a section of articles that adds more as people read. Its keys come from the page's own script, as the ARIA Authoring Practices suggest: Page Down and Page Up move focus from mug to mug, and Ctrl + End moves it to the first link after the feed, Contact us, so the footer can be reached from the keyboard. While focus is past the feed, nothing more loads: without that check, the footer coming into view would load twelve more mugs and push the focused link out of sight. The line above the list names these keys, since nothing else would tell a sighted keyboard user. Each mug is an `article` with `aria-posinset` and `aria-setsize`, so a screen reader that supports them can say where in the 48 a mug is. `aria-busy` is true only while mugs are being added, and the loading message and the count go into the same kind of status line as in the other fix. The articles take focus from the keys (`tabindex="-1"`) but are not Tab stops of their own: each mug's link already is one. New mugs are added after the old ones, so focus is never dropped.

This passes 2.1.1, but only through a key that people have to learn from the line above the list. Tab still walks into every new mug, and screen readers differ in what they do with a feed: the Authoring Practices warn of support gaps, especially on phones.

## Which option to use

Both pass, but use the Load more button. It does the whole job with a native button and a few lines of script, it works the same for the mouse, the keyboard, a screen reader and voice control, and people decide when more mugs come, so the footer is always one Tab past the button. The feed keeps the endless scroll, at the cost of keys that a person has to learn and a pattern whose support varies, which is why it is marked as not enough on its own; choose it only when the design must load as it scrolls, and test it with the screen readers your visitors use. Numbered page links pass too, and a page of results people can return to, bookmark or share is often better still; how to build them so they say where they go is covered in [pagination links that don't say which page or where they go](/learn/pagination).

## Implementation decisions

A framework can drop focus the same way `innerHTML +=` does. A list whose items have no stable key, or that is swapped for a new list each time more load, is rebuilt from scratch, and the focused item goes with it. Key each item by something that belongs to it, such as the product id, and add the new page to the end of the same list.

After Load more, focus goes to the first new mug, not the button. Left on the button, focus would sit after the new mugs, so the next Tab would skip them all to reach the footer, and people would have to go back to find what they asked for. The count is written after focus moves, so the new mug's name comes first and the polite count waits behind it.

A slow or failed load needs saying too: write "Couldn't load more mugs. Try again." into the same status line, and leave the button where it is. What to announce while something loads, and when, is covered in [loading spinners that screen readers never announce](/learn/loading-states). When filters or sorting replace the list, the count of results is the message to announce, as [search and filter results that update without a word](/learn/search-results) explains.

The button can begin as a link to the next page, such as `/mugs?page=2`, so it still works before the script has loaded or when it fails, and the script can turn it into a button. Writing the number of mugs shown into the address with `history.replaceState()` lets Back and a shared link return to the same place in the list.

Some lists load the first batch or two as people scroll and then show a Load more button. That passes, as long as the button appears after a fixed number of loads, so the footer is reached after a known number of mugs. A feed with no known total gives `aria-setsize="-1"`. A feed that removes old articles as new ones load must keep every article's `aria-posinset` right, and must never remove the one that has focus. Many Mac keyboards have no End, Page Up or Page Down key and give them as Fn with an arrow key, so test the feed's keys there too.

## Verify the fix

1. Put the mouse aside and press Tab from the top of the page until focus reaches Contact us. With the button, focus passes the mugs shown and the button, then reaches the footer; in a feed, Ctrl + End reaches it from any mug.
2. Load more mugs: press Show 12 more, or in a feed, Tab down until more load. With the button, focus is on the first new mug; in a feed, it stays on the mug it was on. In neither is focus left on the page itself.
3. With a screen reader, load more mugs. "Loading more mugs…" and then "Showing 24 of 48 mugs" are announced once each, without focus or the reading position moving to the line.
4. In a feed, check the keys: Page Down and Page Up move one mug at a time, Ctrl + Home goes back to the link before the list, and a screen reader says each mug's position, such as 13 of 48, where it supports it.
5. Load until all 48 mugs are shown. The line says "Showing 48 of 48 mugs", the button has gone, and nothing more loads, however far the page is scrolled.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the list, its component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every list. The scripts are minimal sketches: they don't handle errors, filters, sorting or the browser's Back button. A list drawn by a virtualising library that removes rows as they scroll out of view needs its own checks. Product cards with more than one control each, such as a link and an Add to basket button, at least double the presses to get past the list, which makes a Load more button matter more. A scrolling panel inside the page that loads more as it scrolls is reached as described in [scrollable areas that a keyboard can't scroll](/learn/scrollable-regions). Test the complete journey with the assistive technology your visitors use.

## What screen readers do (tested by hand by EaseWeb)

`role="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`role="feed"`: A section of articles that adds more as people read; the ARIA pattern expects screen readers to offer keys that move between its articles and past it.

| Screen reader | Browser and system | Down Arrow |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

`aria-posinset, aria-setsize`: Gives an item its place in a set, and the set its size, when not every item is on the page.

| Screen reader | Browser and system | Down Arrow |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

`aria-busy="true"`: Marks a part of the page as being updated, so assistive technology can wait for the update to finish.

| Screen reader | Browser and system | Down Arrow |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
