🪗 Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
A scrollable list of articles where new content may be added as the user scrolls, enabling keyboard navigation between articles using Page Up/Down.
Navigate articles using Page Down (next) and Page Up (previous). Use Ctrl+End to move focus after the feed, Ctrl+Home to move focus before the feed. Scroll to the bottom to automatically load more articles.
aria-busy: When loading new articles, aria-busy="true" is set on the
feed to prevent screen readers from announcing incomplete content. Once loading completes, it's
set back to false.
A vertically stacked set of interactive headings that each reveal a section of content.
An element that displays a brief, important message in a way that attracts the user's attention without interrupting the user's task.
A modal dialog that interrupts the user's workflow to communicate an important message and acquire a response.
| Role | Target Element | Description |
|---|---|---|
feed | Container element | A dynamic list of articles where scrolling may add/remove content |
article | Each article element | Independent content item within the feed |
WAI-ARIA APG Feed Pattern (opens in new tab)
Note: Feed is a Structure, Not a Widget
Unlike widget patterns (e.g., Listbox, Menu), the feed pattern is a structure. This means assistive technologies can use their default reading mode when navigating feed content. The feed role enables users to navigate articles using Page Up/Down while still allowing natural reading within each article.
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-label | Feed container | Text | conditional* | Accessible name for the feed |
aria-labelledby | Feed container | ID reference | conditional* | References visible heading for the feed |
aria-labelledby | Each article | ID reference | Yes | References the article title element |
aria-describedby | Each article | ID reference | No | References the article description or content |
aria-posinset | Each article | Number (1-based) | Yes | Position of article in the feed |
aria-setsize | Each article | Number or -1 | Yes | Total articles in feed, or -1 if unknown |
Either aria-label or aria-labelledby is required on the feed container. Use aria-labelledby when a visible heading exists.
aria-busy | Target | Feed container |
| Values | true | false |
| Required | Conditional (when loading occurs) |
| Change Trigger | Loading starts (true), loading completes (false). Indicates when the feed is loading new content. Screen readers will wait to announce changes until loading completes. |
| Reference | aria-busy (opens in new tab) |
Note: Set aria-busy to true when adding multiple articles to prevent premature announcements. Set to false after all DOM updates are complete.
| Key | Action |
|---|---|
| Page Down | Move focus to next article in the feed |
| Page Up | Move focus to previous article in the feed |
| Ctrl + End | Move focus to first focusable element after the feed |
| Ctrl + Home | Move focus to first focusable element before the feed |
Why Page Up/Down instead of Arrow keys? Feeds contain long-form content like articles. Using Page Up/Down allows users to navigate between articles while Arrow keys remain available for reading within articles.
Important: Keyboard Documentation
The Feed pattern uses Page Up/Down for navigation, which differs from common widget patterns that use Arrow keys. As the APG specification (opens in new tab) notes: "Due to the lack of convention, providing easily discoverable keyboard interface documentation is especially important." Always display keyboard hints near the feed to help users discover these navigation options.
| Event | Behavior |
|---|---|
| Roving tabindex | Only one article has tabindex="0", others have tabindex="-1" |
| Initial focus | First article has tabindex="0" by default |
| Focus tracking | tabindex updates as focus moves between articles |
| No wrap | Focus does not wrap from first to last article or vice versa |
| Content inside articles | Interactive elements inside articles remain keyboard accessible |
---
/**
* APG Feed Pattern - Astro Implementation
*
* A feed is a section of a page that automatically loads new sections of content
* as the user scrolls. It is a structure (not a widget), allowing assistive
* technologies to use their default reading mode.
*
* Uses Web Components for client-side keyboard navigation and infinite scroll.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/feed/
*/
export interface FeedArticle {
/** Unique identifier for the article */
id: string;
/** Article title (required for aria-labelledby) */
title: string;
/** Optional description (used for aria-describedby) */
description?: string;
/** Article content (plain text) */
content: string;
}
export interface Props {
/** Array of article data */
articles: FeedArticle[];
/** Accessible name for the feed (mutually exclusive with aria-labelledby) */
'aria-label'?: string;
/** ID reference to visible label (mutually exclusive with aria-label) */
'aria-labelledby'?: string;
/**
* Total number of articles
* - undefined: use articles.length (auto-calculate)
* - -1: unknown total (infinite scroll)
* - positive number: explicit total count
*/
setSize?: number;
/** Loading state */
loading?: boolean;
/** Additional CSS class */
class?: string;
/** Instance ID (optional, auto-generated if not provided) */
id?: string;
/** Test ID for E2E testing */
'data-testid'?: string;
/** Disable automatic infinite scroll (manual load only) */
disableAutoLoad?: boolean;
/** Intersection Observer root margin for triggering load (default: "200px") */
loadMoreRootMargin?: string;
}
const {
articles,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
setSize,
loading = false,
class: className = '',
id,
'data-testid': testId,
disableAutoLoad = false,
loadMoreRootMargin = '200px',
} = Astro.props;
// Generate unique ID for this instance
const instanceId = id || `feed-${Math.random().toString(36).substring(2, 11)}`;
// Calculate set size
const computedSetSize = setSize !== undefined ? setSize : articles.length;
---
<apg-feed
data-disable-auto-load={disableAutoLoad ? 'true' : undefined}
data-load-more-root-margin={loadMoreRootMargin}
>
<div
role="feed"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-busy={loading}
class:list={['apg-feed', className]}
id={id}
data-testid={testId}
>
{
articles.map((article, index) => {
const titleId = `${instanceId}-article-${article.id}-title`;
const descId = article.description ? `${instanceId}-article-${article.id}-desc` : undefined;
const isFirst = index === 0;
return (
<article
role="article"
class="apg-feed-article"
tabindex={isFirst ? 0 : -1}
aria-labelledby={titleId}
aria-describedby={descId}
aria-posinset={index + 1}
aria-setsize={computedSetSize}
data-article-id={article.id}
data-article-index={index.toString()}
>
<h3 id={titleId}>{article.title}</h3>
{article.description && <p id={descId}>{article.description}</p>}
<div class="apg-feed-article-content">{article.content}</div>
</article>
);
})
}
<!-- Sentinel element for infinite scroll detection -->
{!disableAutoLoad && <div class="apg-feed-sentinel" aria-hidden="true" />}
</div>
<script>
class ApgFeed extends HTMLElement {
private feedElement: HTMLElement | null = null;
private articles: HTMLElement[] = [];
private focusedIndex = 0;
private observer: IntersectionObserver | null = null;
private sentinel: HTMLElement | null = null;
private boundHandleKeyDown: ((event: KeyboardEvent) => void) | null = null;
connectedCallback() {
this.feedElement = this.querySelector('[role="feed"]');
if (!this.feedElement) return;
this.articles = Array.from(this.feedElement.querySelectorAll('[role="article"]'));
if (this.articles.length === 0) return;
// Set up event listeners (store bound function for cleanup)
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
this.feedElement.addEventListener('keydown', this.boundHandleKeyDown);
// Set up focus tracking
this.articles.forEach((article, index) => {
article.addEventListener('focus', () => this.handleArticleFocus(index));
});
// Set up Intersection Observer for infinite scroll
this.setupIntersectionObserver();
}
disconnectedCallback() {
// Clean up event listeners
if (this.feedElement && this.boundHandleKeyDown) {
this.feedElement.removeEventListener('keydown', this.boundHandleKeyDown);
}
// Clean up observer
if (this.observer) {
this.observer.disconnect();
}
}
private setupIntersectionObserver() {
const disableAutoLoad = this.dataset.disableAutoLoad === 'true';
if (disableAutoLoad) return;
this.sentinel = this.querySelector('.apg-feed-sentinel');
if (!this.sentinel) return;
const rootMargin = this.dataset.loadMoreRootMargin || '200px';
this.observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
const feedElement = this.feedElement;
if (
entry.isIntersecting &&
feedElement &&
feedElement.getAttribute('aria-busy') !== 'true'
) {
// Dispatch custom event for infinite scroll
this.dispatchEvent(
new CustomEvent('feed:loadmore', {
bubbles: true,
composed: true,
})
);
}
},
{
rootMargin,
threshold: 0,
}
);
this.observer.observe(this.sentinel);
}
private handleKeyDown(event: KeyboardEvent) {
const { target } = event;
if (!(target instanceof HTMLElement)) return;
let currentIndex = this.focusedIndex;
// Find current article
for (let i = 0; i < this.articles.length; i++) {
const article = this.articles[i];
if (article === target || article.contains(target)) {
currentIndex = i;
break;
}
}
switch (event.key) {
case 'PageDown':
event.preventDefault();
if (currentIndex < this.articles.length - 1) {
this.focusArticle(currentIndex + 1);
}
break;
case 'PageUp':
event.preventDefault();
if (currentIndex > 0) {
this.focusArticle(currentIndex - 1);
}
break;
case 'End':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
this.focusOutsideFeed('after');
}
break;
case 'Home':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
this.focusOutsideFeed('before');
}
break;
}
}
private focusArticle(index: number) {
const article = this.articles[index];
if (!article) return;
// Update tabindex
this.articles.forEach((a, i) => {
a.setAttribute('tabindex', i === index ? '0' : '-1');
});
article.focus();
this.focusedIndex = index;
// Dispatch focus change event
this.dispatchEvent(
new CustomEvent('feed:focuschange', {
bubbles: true,
composed: true,
detail: {
articleId: article.dataset.articleId,
index,
},
})
);
}
private handleArticleFocus(index: number) {
// Update tabindex
this.articles.forEach((a, i) => {
a.setAttribute('tabindex', i === index ? '0' : '-1');
});
this.focusedIndex = index;
}
private focusOutsideFeed(direction: 'before' | 'after') {
if (!this.feedElement) return;
const focusableSelector =
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])';
const allFocusable = Array.from(document.querySelectorAll<HTMLElement>(focusableSelector));
// Find the index range of feed elements
let feedStartIndex = -1;
let feedEndIndex = -1;
for (let i = 0; i < allFocusable.length; i++) {
if (this.feedElement.contains(allFocusable[i]) || allFocusable[i] === this.feedElement) {
if (feedStartIndex === -1) feedStartIndex = i;
feedEndIndex = i;
}
}
if (direction === 'before') {
if (feedStartIndex > 0) {
allFocusable[feedStartIndex - 1].focus();
}
} else {
if (feedEndIndex >= 0 && feedEndIndex < allFocusable.length - 1) {
allFocusable[feedEndIndex + 1].focus();
}
}
}
}
customElements.define('apg-feed', ApgFeed);
</script>
</apg-feed>
<style>
/* Styles are in src/styles/patterns/feed.css */
.apg-feed-sentinel {
height: 1px;
visibility: hidden;
}
</style> ---
import Feed from './Feed.astro';
const articles = [
{
id: 'article-1',
title: 'Getting Started with Astro',
description: 'Learn the basics of Astro development',
content: '<p>Full article content here...</p>'
},
{
id: 'article-2',
title: 'Advanced Patterns',
description: 'Explore advanced Astro patterns',
content: '<p>Full article content here...</p>'
}
];
---
<Feed
articles={articles}
aria-label="Blog posts"
setSize={-1}
loading={false}
/> | Prop | Type | Default | Description |
|---|---|---|---|
articles | FeedArticle[] | required | Array of article items |
aria-label | string | conditional | Accessible name (required if no aria-labelledby) |
aria-labelledby | string | conditional | ID reference to visible heading |
setSize | number | articles.length | Total count or -1 if unknown |
loading | boolean | false | Loading state (sets aria-busy) |
| Event | Detail | Description |
|---|---|---|
feed:loadmore | - | Dispatched to load more articles |
feed:focuschange | { articleId: string, index: number } | Dispatched when focus changes |
interface FeedArticle {
id: string;
title: string;
description?: string;
content: string;
} Tests verify APG compliance across ARIA structure, keyboard navigation, focus management, and dynamic loading states. The Feed component uses a two-layer testing strategy.
Verify the component's HTML output and basic interactions. These tests ensure correct template rendering and ARIA attributes.
Verify component behavior in a real browser environment. These tests cover interactions that require JavaScript execution.
| Test | Description |
|---|---|
role="feed" | Container has feed role |
role="article" | Each item has article role |
aria-label/labelledby (feed) | Feed container has accessible name |
aria-labelledby (article) | Each article references its title |
aria-posinset | Sequential starting from 1 |
aria-setsize | Total count or -1 if unknown |
| Test | Description |
|---|---|
Page Down | Moves focus to next article |
Page Up | Moves focus to previous article |
No wrap | Does not loop at first/last article |
Ctrl+End | Moves focus after the feed |
Ctrl+Home | Moves focus before the feed |
Inside article | Page Down works from inside article elements |
| Test | Description |
|---|---|
Roving tabindex | Only one article has tabindex="0" |
tabindex update | tabindex updates when focus moves |
Initial state | First article has tabindex="0" by default |
| Test | Description |
|---|---|
aria-busy="false" | Default state when not loading |
aria-busy="true" | Set during loading |
Focus maintenance | Focus maintained during loading |
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations |
Loading state | No axe violations during loading |
aria-describedby | Present when description provided |
# Run all Feed unit tests
npm run test:unit -- Feed
# Run framework-specific tests
npm run test:react -- Feed.test.tsx
# Run all Feed E2E tests
npm run test:e2e -- feed.spec.ts
# Run in UI mode
npm run test:e2e:ui -- feed.spec.ts
/**
* Feed Astro Component Tests using Container API
*
* These tests verify the Feed.astro component output using Astro's Container API.
* This ensures the component renders correct ARIA structure and attributes.
*
* Note: Interactive behavior (keyboard navigation, Ctrl+Home/End) is tested in E2E tests.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Feed from './Feed.astro';
describe('Feed (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
// Helper to render and parse HTML
async function renderFeed(props: {
articles: Array<{ id: string; title: string; description?: string; content: string }>;
'aria-label'?: string;
'aria-labelledby'?: string;
setSize?: number;
loading?: boolean;
class?: string;
id?: string;
}): Promise<Document> {
const html = await container.renderToString(Feed, { props });
const dom = new JSDOM(html);
return dom.window.document;
}
const basicArticles = [
{
id: 'article-1',
title: 'First Article',
description: 'Description 1',
content: '<p>Content 1</p>',
},
{
id: 'article-2',
title: 'Second Article',
description: 'Description 2',
content: '<p>Content 2</p>',
},
{
id: 'article-3',
title: 'Third Article',
description: 'Description 3',
content: '<p>Content 3</p>',
},
];
const fiveArticles = [
{ id: 'article-1', title: 'Article 1', content: '<p>Content 1</p>' },
{ id: 'article-2', title: 'Article 2', content: '<p>Content 2</p>' },
{ id: 'article-3', title: 'Article 3', content: '<p>Content 3</p>' },
{ id: 'article-4', title: 'Article 4', content: '<p>Content 4</p>' },
{ id: 'article-5', title: 'Article 5', content: '<p>Content 5</p>' },
];
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA Structure', () => {
it('has role="feed" on container', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed).not.toBeNull();
});
it('has role="article" on each article', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
expect(articles).toHaveLength(3);
});
it('has aria-label on feed when provided', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed?.getAttribute('aria-label')).toBe('News Feed');
});
it('has aria-labelledby on feed when provided', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-labelledby': 'feed-title',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed?.getAttribute('aria-labelledby')).toBe('feed-title');
});
it('has aria-labelledby on each article referencing title', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article) => {
const labelledby = article.getAttribute('aria-labelledby');
expect(labelledby).toBeTruthy();
const titleElement = doc.getElementById(labelledby!);
expect(titleElement).not.toBeNull();
});
});
it('has aria-describedby on articles when description provided', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article) => {
const describedby = article.getAttribute('aria-describedby');
expect(describedby).toBeTruthy();
const descElement = doc.getElementById(describedby!);
expect(descElement).not.toBeNull();
});
});
it('has aria-posinset starting from 1 and sequential', async () => {
const doc = await renderFeed({
articles: fiveArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article, index) => {
expect(article.getAttribute('aria-posinset')).toBe(String(index + 1));
});
});
it('has aria-setsize as total count when known', async () => {
const doc = await renderFeed({
articles: fiveArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article) => {
expect(article.getAttribute('aria-setsize')).toBe('5');
});
});
it('has aria-setsize as -1 when setSize is -1', async () => {
const doc = await renderFeed({
articles: fiveArticles,
'aria-label': 'News Feed',
setSize: -1,
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article) => {
expect(article.getAttribute('aria-setsize')).toBe('-1');
});
});
it('has aria-setsize as explicit value when provided', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
setSize: 100,
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article) => {
expect(article.getAttribute('aria-setsize')).toBe('100');
});
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('article elements have tabindex', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
articles.forEach((article) => {
expect(article.hasAttribute('tabindex')).toBe(true);
});
});
it('uses roving tabindex (only first article has tabindex="0")', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
expect(articles[0]?.getAttribute('tabindex')).toBe('0');
expect(articles[1]?.getAttribute('tabindex')).toBe('-1');
expect(articles[2]?.getAttribute('tabindex')).toBe('-1');
});
});
// 🔴 High Priority: Dynamic Loading State
describe('APG: Dynamic Loading', () => {
it('has aria-busy="false" by default', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed?.getAttribute('aria-busy')).toBe('false');
});
it('has aria-busy="true" when loading', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
loading: true,
});
const feed = doc.querySelector('[role="feed"]');
expect(feed?.getAttribute('aria-busy')).toBe('true');
});
});
// 🟢 Low Priority: HTML Attributes
describe('HTML Attributes', () => {
it('applies class to container', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
class: 'custom-feed',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed?.classList.contains('custom-feed')).toBe(true);
});
it('applies id to container', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
id: 'my-feed',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed?.getAttribute('id')).toBe('my-feed');
});
});
// Content Rendering
describe('Content Rendering', () => {
it('renders article titles', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
expect(doc.body.textContent).toContain('First Article');
expect(doc.body.textContent).toContain('Second Article');
expect(doc.body.textContent).toContain('Third Article');
});
it('renders article descriptions when provided', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
expect(doc.body.textContent).toContain('Description 1');
expect(doc.body.textContent).toContain('Description 2');
expect(doc.body.textContent).toContain('Description 3');
});
it('renders article content', async () => {
const doc = await renderFeed({
articles: basicArticles,
'aria-label': 'News Feed',
});
expect(doc.body.innerHTML).toContain('Content 1');
expect(doc.body.innerHTML).toContain('Content 2');
expect(doc.body.innerHTML).toContain('Content 3');
});
});
// Edge Cases
describe('Edge Cases', () => {
it('handles empty articles array', async () => {
const doc = await renderFeed({
articles: [],
'aria-label': 'Empty Feed',
});
const feed = doc.querySelector('[role="feed"]');
expect(feed).not.toBeNull();
const articles = doc.querySelectorAll('[role="article"]');
expect(articles).toHaveLength(0);
});
it('handles single article', async () => {
const singleArticle = [{ id: '1', title: 'Only Article', content: '<p>Content</p>' }];
const doc = await renderFeed({
articles: singleArticle,
'aria-label': 'Single Article Feed',
});
const articles = doc.querySelectorAll('[role="article"]');
expect(articles).toHaveLength(1);
expect(articles[0]?.getAttribute('aria-posinset')).toBe('1');
expect(articles[0]?.getAttribute('aria-setsize')).toBe('1');
});
it('handles article without description', async () => {
const noDescArticles = [{ id: '1', title: 'No Description', content: '<p>Content</p>' }];
const doc = await renderFeed({
articles: noDescArticles,
'aria-label': 'Feed',
});
const article = doc.querySelector('[role="article"]');
// Should not have aria-describedby when no description
expect(article?.hasAttribute('aria-describedby')).toBe(false);
});
});
});