Data Display
Feed
AvailableMakes an infinitely scrolling article list navigable and readable with assistive technologies.
What is a Feed?
A feed is a list UI that displays articles or posts vertically,automatically loading more content as the user scrolls to the bottom. It is commonly used for social media timelines, news lists, and blog article streams.
What sets a feed apart from a regular list is that the number of items grows dynamically. This is precisely why it's important to convey "which item out of how many the user is currently reading" and "whether new content is currently loading" — even to people who cannot see the screen.
Why Does Accessibility Matter?
Infinite scroll, when implemented incorrectly, leaves these people behind:
- Screen reader users. If articles are just a series of
<div>elements, there's no way to convey "this is a list of articles" or "item 3 of 10." Users lose track of their position. They also can't tell whether content is loading, leaving them in anxious silence. - Keyboard-only users. Tabbing through every link inside each article with Tab is exhausting. Without the ability to jump between articles, navigating a long list becomes impractical.
The solution is to mark the container as role="feed" and each article as role="article", use aria-posinset / aria-setsize for position and total count, and aria-busy for loading state. Then enable PageDown / PageUp navigation between articles.
Live Demo (Recommended Implementation)
The feed below follows the APG pattern. Focus on an article and try navigating through the list without using a mouse — keyboard only.
Making Morning Walks a Habit
Just 20 minutes of walking each morning clears my head and improves my focus for the whole day. The trick is not limiting yourself to only walking on nice days.
Storage Tips for a Small Kitchen
Even with limited space, making use of vertical areas and the back of doors can dramatically increase your storage capacity. Keep frequently used items at arm's reach.
Starting a Reading Journal
Writing just three lines about each book after finishing it — that small habit alone makes it so much easier to recall what I read later on.
Try it: Tab to the first article → PageDown / PageUp to move between articles → Ctrl+End to move outside the feed → Click 'Load more' to add articles — focus moves to the first new article (you won't get lost).
Tip
When a screen reader focuses on an article, it announces something like "article, Making Morning Walks a Habit, 1 of 3" — conveying the article title, current position, and total count. This is the effect of aria-labelledby combined with aria-posinset / aria-setsize.
Keyboard Interaction
| Key | Action | Priority |
|---|---|---|
| PageDown | Move focus to the next article | Recommended |
| PageUp | Move focus to the previous article | Recommended |
| Tab | Move to the next focusable element within the article, such as a link | Required |
| Ctrl + Home / Ctrl + End | Move to the first focusable element before / after the feed (exit the feed) | Recommended |
Note
PageDown / PageUp is used for moving between articles so thatTab remains available for navigating links within an article. The handler calls preventDefault() to suppress the browser's default scroll behavior.
Required WAI-ARIA Roles and Properties
| Target | Attribute / Role | Meaning |
|---|---|---|
| Container | role="feed" | Indicates a dynamically growing, scrollable list of articles. |
| Container | aria-busy="true | false" | Set to true during loading. Must be set back to false after loading completes. |
| Container | aria-label / aria-labelledby | Provides an accessible name for the feed (e.g., "Latest articles"). |
| Each article | role="article" + tabindex="0" | Identifies each item as an article and makes it focusable. |
| Each article | aria-labelledby="title-id" | Associates the article with its heading. Read aloud when the article receives focus. |
| Each article | aria-posinset + aria-setsize | Indicates "item N of M." Must be updated across all articles when the total count changes. |
Implementation: Recommended Pattern (Good)
Good / Recommended
Use a role="feed" > role="article" structure and keep position, count, and loading state in sync via ARIA attributes.
Markup:
<div role="feed" aria-busy="false" aria-label="Latest articles">
<article
role="article"
tabindex="0"
aria-labelledby="post-1-title"
aria-posinset="1"
aria-setsize="3">
<h3 id="post-1-title">Making Morning Walks a Habit</h3>
<p>Just 20 minutes of walking each morning clears my head and…</p>
</article>
<article
role="article"
tabindex="0"
aria-labelledby="post-2-title"
aria-posinset="2"
aria-setsize="3">
<h3 id="post-2-title">Storage Tips for a Small Kitchen</h3>
<p>Even with limited space, a little creativity goes a long way…</p>
</article>
<!-- …more articles follow -->
</div>
<button type="button" id="load-more">Load more</button>Navigation between articles and loading more (managing position, count, aria-busy, and focus):
const feed = document.getElementById('feed-region');
const loadBtn = document.getElementById('feed-load');
// Navigate between articles (PageDown / PageUp / Ctrl+Home / Ctrl+End)
function getArticles() {
return Array.from(feed.querySelectorAll('[role="article"]'));
}
feed.addEventListener('keydown', (e) => {
// Ctrl+Home / Ctrl+End: move focus outside the feed
if (e.ctrlKey && e.key === 'Home') {
e.preventDefault();
feed.previousElementSibling?.focus?.();
return;
}
if (e.ctrlKey && e.key === 'End') {
e.preventDefault();
loadBtn.focus();
return;
}
const articles = getArticles();
const current = document.activeElement?.closest('[role="article"]');
const index = articles.indexOf(current);
if (index === -1) return;
let target = null;
if (e.key === 'PageDown') target = articles[index + 1];
else if (e.key === 'PageUp') target = articles[index - 1];
if (target) {
e.preventDefault(); // Prevent default scroll behavior
target.focus();
}
});
// Load more: update setsize/posinset, set aria-busy="true" during load,
// then move focus to the first newly added article.
loadBtn.addEventListener('click', () => {
feed.setAttribute('aria-busy', 'true'); // ← Notify assistive tech of loading state
setTimeout(() => {
const total = getArticles().length + 2;
// …generate new <article> elements with posinset / setsize and append…
getArticles().forEach((a, i) => {
a.setAttribute('aria-posinset', String(i + 1));
a.setAttribute('aria-setsize', String(total));
});
feed.setAttribute('aria-busy', 'false');
firstNewArticle.focus(); // ← Don't let focus get lost
}, 600);
});Note
When loading more content, it's best practice to move focus to the first newly added article. Without this, focus jumps to the top of the document when the DOM changes, causing the user to lose their place. Also make sure to update aria-setsize on all articles with the new total count.
Anti-pattern (Bad)
Below is a feed built by simply stacking <div> elements — it only looks the same.You can scroll with a mouse, but there's no way to jump between articles, and assistive technologies receive no information about the list structure, current position, or loading state.
Making Morning Walks a Habit
Just 20 minutes of walking each morning clears my head and improves my focus for the whole day.
Storage Tips for a Small Kitchen
Even with limited space, making use of vertical areas and the back of doors can dramatically increase your storage capacity.
Starting a Reading Journal
Writing just three lines about each book after finishing it — that small habit alone makes it so much easier to recall.
Try it: Articles can't receive focus and PageDown doesn't move between them. Clicking 'Load more' adds articles, but focus gets lost and there's no indication of position or count.
<!-- ❌ Anti-pattern -->
<!-- Just stacked divs. No feed/article roles, no position or count info -->
<div class="list">
<div class="card">
<h3>Making Morning Walks a Habit</h3>
<p>Just 20 minutes of walking each morning…</p>
</div>
<div class="card">
<h3>Storage Tips for a Small Kitchen</h3>
<p>Even with limited space…</p>
</div>
</div>
<div class="more" onclick="loadMore()">Load more</div>Bad / Avoid
Problems with this implementation:
- List structure is not conveyed — Without
role="feed"/role="article", it sounds like an unstructured block of text. - Position is unknown — Without
aria-posinset/aria-setsize, there's no way to indicate "item X of Y." - Loading state is not communicated — Without
aria-busy, assistive technologies remain silent when new content loads. - Can't navigate between articles — Articles can't receive focus, and PageDown / PageUp article jumping doesn't work.
- Focus gets lost — No focus management after loading more content, causing users to lose their place.
Tip
Even with infinite scroll, following the APG feed pattern lets you properly communicate "current position, total count, and loading state." When using scroll-triggered auto-loading,also provide a "Load more" button as a reliable alternative for keyboard users.
Implementation Checklist
- The container has role="feed" and is named via aria-label or similar
- Each article has role="article" + tabindex="0" and is focusable
- Each article has a heading associated via aria-labelledby
- aria-posinset / aria-setsize always match the current position and total count
- aria-busy="true" is set during loading and returns to false upon completion
- PageDown / PageUp navigates between articles (default scroll is prevented with preventDefault)
- Focus is not lost after additional content loads (move focus to the first new item)
- Content can be read using keyboard only, and focus is visible (focus ring is not hidden)
Source (English):Feed Pattern — W3C APG(opens in a new tab)