The Accessibility Cost of Infinite Scroll (and What to Ship Instead)

A person scrolling a smartphone feed with one hand, screen lit against a dark background

A Colombian newspaper ran a piece recently about infinite scroll as a thief of time, with psychologists explaining the reward loop and readers being told to set alarms every fifteen minutes. It is a fair article. It is also aimed at the wrong audience. The people who can actually fix infinite scroll are not the ones scrolling. They are the ones shipping it.

Aza Raskin built the pattern in 2006 as a navigation convenience and has spent years since saying he regrets it. Twenty years later it is a default. Somebody reaches for a feed, somebody wires up an IntersectionObserver, and the last page quietly stops existing.

Here is the part the wellness coverage misses: infinite scroll is not only an attention problem. It is one of the most reliable ways to ship an inaccessible page. I have audited enough interfaces against WCAG for the US market to say that with some confidence. The same design decision that removes the ending for a casual reader removes the entire footer for a keyboard user.

What the pattern actually removes

Physical media has closure signals built in. A book has a last page. A film has credits. A paginated list has a page 4 of 4. Those endings are not decoration, they are the moment a person gets to decide whether to continue.

Infinite scroll deletes that moment. And once you delete it, four technical failures tend to follow, all of them mine to fix as the person writing the component.

1. The footer becomes unreachable

This is the one that shows up in every audit. Your privacy policy, contact link, accessibility statement, and legal pages live in the footer. A sighted mouse user can hammer End and maybe catch it. A keyboard user tabbing forward never arrives, because every time they approach the bottom, twenty new focusable links get injected ahead of the footer. The footer recedes forever.

That is a WCAG 2.1.1 and 2.4.3 problem, and in a US compliance context it is the kind of finding that ends up in a demand letter. It is also completely avoidable.

2. New content arrives silently

You appended thirty articles to the DOM. A screen reader user has no idea. There was no announcement, no focus change, no status message. From their side the page simply did nothing, forever.

3. Back navigation loses the position

User scrolls through 200 items, opens one, hits back, and lands at item 1. This is not a minor annoyance, it is the single most common complaint about infinite scroll in ecommerce catalogs, and it is a direct revenue problem. Every restart is an abandonment opportunity.

4. The page gets heavier until it dies

Without virtualization, node count grows without bound. Layout and paint costs climb with it, CLS degrades as images settle in, and on a mid range Android phone the thing eventually just stops responding. Anyone who has run Lighthouse on a long feed has seen the curve.

The default that should have been the default

A Load more button. That is it. It costs one click and it fixes almost everything above at once: the footer stays reachable, the user keeps an explicit decision point, back navigation is trivial to restore, and the growth of the DOM is bounded by intent instead of by inertia.

function ResultsList({ items, hasMore, onLoadMore, loading }) {
  const [announcement, setAnnouncement] = useState("");
  const firstNewRef = useRef(null);
  const previousCount = useRef(items.length);

  useEffect(() => {
    const added = items.length - previousCount.current;
    if (added > 0) {
      setAnnouncement(`${added} more results loaded. ${items.length} of ${total} shown.`);
      firstNewRef.current?.focus();
    }
    previousCount.current = items.length;
  }, [items.length]);

  return (
    <>
      <ul>
        {items.map((item, i) => (
          <li key={item.id}>
            <a
              href={item.href}
              ref={i === previousCount.current ? firstNewRef : null}
              tabIndex={-1}
            >
              {item.title}
            </a>
          </li>
        ))}
      </ul>

      <p role="status" aria-live="polite" className="visually-hidden">
        {announcement}
      </p>

      {hasMore && (
        <button type="button" onClick={onLoadMore} disabled={loading}>
          {loading ? "Loading…" : "Load more results"}
        </button>
      )}
    </>
  );
}

Three details in there matter more than the rest. The role="status" region with aria-live="polite" announces the change without interrupting whatever the user is reading. Moving focus to the first newly added item means a keyboard user resumes exactly where the new content begins rather than at the top. And the count in the announcement, 60 of 240 shown, is the closure signal the pattern threw away. People deserve to know how much is left.

Keep visually-hidden as a clip based utility, not display: none. Hidden elements are removed from the accessibility tree and your announcement will never fire.

If you genuinely need automatic loading

Sometimes the product decision is already made and you are not going to win that argument. Fine. You can still make it survive an audit by treating the observer as a progressive enhancement on top of a real button rather than a replacement for one.

const sentinelRef = useRef(null);
const [autoLoads, setAutoLoads] = useState(0);
const MAX_AUTO_LOADS = 3;

useEffect(() => {
  const el = sentinelRef.current;
  if (!el || !hasMore || autoLoads >= MAX_AUTO_LOADS) return;

  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reducedMotion) return;

  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        onLoadMore();
        setAutoLoads((n) => n + 1);
      }
    },
    { rootMargin: "400px" }
  );

  observer.observe(el);
  return () => observer.disconnect();
}, [hasMore, autoLoads, onLoadMore]);

The cap is the whole point. After three automatic loads the sentinel stops firing and the button takes over permanently. The user gets the frictionless experience the product manager wanted for the first stretch, and then the interface hands control back. The footer becomes reachable again. Nobody scrolls for forty minutes by accident.

The rootMargin of 400px preloads before the user hits the bottom so it still feels instant. And respecting prefers-reduced-motion here is not strictly required by the spec, but users who set it are generally telling you they do not want the page moving on its own.

Restoring scroll position without a library

The back button problem has a boring fix that almost nobody implements: put the page count in the URL and let the browser do its job.

// after each successful load
const url = new URL(window.location);
url.searchParams.set("loaded", String(page));
window.history.replaceState({ page, scrollY: window.scrollY }, "", url);

// on mount
const restore = Number(new URLSearchParams(location.search).get("loaded")) || 1;
// fetch pages 1..restore, then:
requestAnimationFrame(() => {
  window.scrollTo(0, history.state?.scrollY ?? 0);
});

Use replaceState rather than pushState so you are not stuffing the history stack with one entry per scroll batch. Refetching several pages on return is not free, but it is cheaper than losing the session, and it makes the URL shareable at the depth the user actually reached.

When infinite scroll is the right call

It is not always wrong. The distinction that has held up for me is whether the user has a task.

  • Browsing without a goal, like a social feed or a photo gallery, is where the pattern is honest. There is no last item and no completion state to deny anyone.
  • Search results and product catalogs are where it hurts most. The user is comparing, wants to know how many options exist, and needs to get back to item 47. Pagination or Load more wins on every metric that matters here.
  • Documentation, tables, and anything with a footer that carries obligations should never use it. Legal links, contact routes, and accessibility statements cannot live behind a receding horizon.
  • Admin and data tools need offsets and counts. Users of internal tools ask how many rows, not how much vibe.

The part that is on us

The advice in consumer articles is all downstream: set a timer, grayscale the screen, leave the phone in another room. None of it is bad. All of it asks the user to individually out discipline a system built by teams of people who were paid to remove their stopping points.

We are on the other side of that. A count in an announcement, a button after three loads, a footer that stays where you put it. These are half a day of work and they hand people back the thing the pattern quietly took, which is the ability to notice they are finished.

The accessibility case and the ethical case turn out to be the same case. That happens more often than you would expect.


Context and reporting on the consumer side of this pattern: El Tiempo, Dejar el scroll infinito y volver a ser dueño de su tiempo (Spanish).

I build and audit frontends for accessibility compliance, including WCAG 2.2 AA and ADA readiness for the US market. If your team is shipping a catalog, feed, or search experience and nobody has run it through a keyboard yet, write to me at serbeldiaz@gmail.com.

CATEGORIES:

Frontend

No responses yet

Leave a Reply

Your email address will not be published. Required fields are marked *

Latest Comments

No comments to show.