Getting Started with Accessible Components
Learn the fundamentals of building accessible web components.
A scrollable list of articles where new content may be added as the user scrolls, enabling keyboard navigation between articles using Page Up/Down.
🤖 AI Implementation GuideNavigate 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.
Learn the fundamentals of building accessible web components.
Deep dive into the APG Feed pattern implementation.
Tips for implementing effective keyboard navigation.
| 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 | Recommended | References the article description or content |
aria-posinset | Each article | Number (1-based) | Yes | Position of article in the feed (starts at 1) |
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-busyIndicates when the feed is loading new content. Screen readers will wait to announce changes until loading completes.
| Target | Feed container |
| Values | true | false |
| Required | Conditional (when loading occurs) |
| Change Trigger | Loading starts (true), loading completes (false) |
| Reference | aria-busy (opens in new tab) |
Note: Set 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.
| Behavior | Description |
|---|---|
| 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}>
<a href="#" class="apg-feed-article-title-link" onclick="event.preventDefault()">
{article.title}
</a>
</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
npm run test:vue -- Feed.test.vue.ts
npm run test:svelte -- Feed.test.svelte.ts
npm run test:astro # 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);
});
});
});