Feed
An ARIA feed is a stream of articles: a timeline, a comment thread, an infinite-scrolling list. Each article is a focus stop, and the user can page between them with the keyboard.
The pattern exists because an endless list breaks normal reading order. A feed
tells assistive technology how many articles there are (aria-setsize), where
each one sits in that set (aria-posinset), and when more are loading
(aria-busy).
import {
Feed,
FeedLabel,
FeedContent,
FeedArticle,
FeedArticleLabel,
FeedArticleDescription,
} from 'terracotta/feed';Anatomy
<Feed size busy> {/* owns size/busy, focus in and out of the feed */}
<FeedLabel/> {/* names the feed */}
<FeedContent> {/* role="feed", handles PageUp/PageDown */}
<FeedArticle index> {/* one article, focusable */}
<FeedArticleLabel/> {/* names the article */}
<FeedArticleDescription/>
</FeedArticle>
</FeedContent>
</Feed>Examples
A timeline
Where you know it, size should be the total number of articles in the feed,
not the number currently rendered. That is what tells a screen reader "3 of
120".
Loading more
Set busy while you fetch. The feed is then not announced mid-update:
const [posts, setPosts] = createSignal<Post[]>([]);
const [total, setTotal] = createSignal(0);
const [loading, setLoading] = createSignal(false);
async function loadMore(): Promise<void> {
setLoading(true);
const page = await fetchPage(posts().length);
setPosts(previous => [...previous, ...page.items]);
setTotal(page.total);
setLoading(false);
}
<Feed class="feed" size={total()} busy={loading()}>
<FeedLabel class="feed-label">Latest posts</FeedLabel>
<FeedContent class="feed-content">
<For each={posts()}>{/* … */}</For>
</FeedContent>
<button type="button" onClick={loadMore} disabled={loading()}>
Load more
</button>
</Feed>/* The busy state is a real ARIA attribute, so it styles directly */
.feed-content[aria-busy="true"] {
opacity: 0.6;
cursor: progress;
}Articles with interactive content
An article is a focus stop. Anything focusable inside it stays reachable by Tab as usual:
<FeedArticle class="feed-article" index={index()}>
<FeedArticleLabel class="feed-article-title">{post.title}</FeedArticleLabel>
<FeedArticleDescription class="feed-article-body">{post.excerpt}</FeedArticleDescription>
<div class="feed-article-actions">
<button type="button" onClick={() => like(post)}>Like</button>
<a href={`/posts/${post.id}`}>Read more</a>
</div>
</FeedArticle>.feed-article-actions {
display: flex;
gap: 0.5rem;
margin-block-start: 0.75rem;
}Highlighting the current article
The article is focusable, so :focus-within marks the one the user is on. That
holds even when focus is on a button inside it:
.feed-article:focus-within {
border-color: #2563eb;
background: #f8fafc;
}A comment thread
The same component with different semantics. Change the elements with as:
<Feed class="feed" size={comments().length}>
<FeedLabel class="feed-label" as="h2">Comments</FeedLabel>
<FeedContent class="feed-content" as="ol">
<For each={comments()}>
{(comment, index) => (
<FeedArticle class="comment" as="li" index={index()}>
<FeedArticleLabel class="comment-author">{comment.author}</FeedArticleLabel>
<FeedArticleDescription class="comment-body">
{comment.body}
</FeedArticleDescription>
</FeedArticle>
)}
</For>
</FeedContent>
</Feed>.feed-content { margin: 0; padding: 0; list-style: none; }
.comment {
border-block-end: 1px solid #e4e4e7;
padding-block: 0.75rem;
}
.comment-author { font-weight: 500; }
.comment-body { margin: 0.25rem 0 0; }A skeleton while loading
<FeedContent class="feed-content">
<For each={posts()}>{/* … */}</For>
<Show when={loading()}>
<div class="feed-skeleton" aria-hidden="true" />
</Show>
</FeedContent>.feed-skeleton {
block-size: 5rem;
border-radius: 0.5rem;
background: linear-gradient(90deg, #f4f4f5, #e4e4e7, #f4f4f5);
background-size: 200% 100%;
animation: shimmer 1.2s linear infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.feed-skeleton { animation: none; }
}State attributes
Feed has no selection or open state, so the markers are the only tc-
attributes it writes. Its live state, meaning size, position and busy, goes
through real ARIA attributes instead, because that is what assistive technology reads.
| Element | Attribute | Present when |
|---|---|---|
Feed |
tc-feed |
Always |
FeedLabel |
tc-feed-label |
Always |
FeedContent |
tc-feed-content |
Always |
FeedContent |
aria-busy |
"true" while busy is set on the Feed |
FeedArticle |
tc-feed-article |
Always |
FeedArticle |
tc-owner |
Always. Ties the article to the feed's keyboard navigation |
FeedArticle |
aria-posinset, aria-setsize |
Always. One-based position, and the feed's size |
FeedArticleLabel |
tc-feed-article-label |
Always |
FeedArticleDescription |
tc-feed-article-description |
Always |
Styling
[tc-feed-content] {
display: flex;
flex-direction: column;
gap: 1rem;
}
[tc-feed-article] {
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
padding: 1rem;
}
/* Busy is a real ARIA attribute, so style from it directly */
[tc-feed-content][aria-busy="true"] { opacity: 0.6; }
/* Position-aware styling, straight from the ARIA attribute */
[tc-feed-article][aria-posinset="1"] { border-color: #2563eb; }
[tc-feed-article]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}Reading the state in code
Feed exposes no state object and no render prop. size and busy are props
you already own, and the position of each article is the index you passed.
Keep them in your own signals, as in the "loading more" example above.
Keyboard
| Key | Action |
|---|---|
| Page Down | Next article |
| Page Up | Previous article |
| Ctrl+End | Moves focus to the first focusable element after FeedContent |
| Ctrl+Home | Moves focus to the last focusable element before FeedContent |
| Tab | Moves through every article and every focusable element inside them |
Article navigation does not wrap. Page Down on the last article stays
put. The Ctrl shortcuts search the whole document, which is what lets a user
escape a long feed without tabbing through it. They skip everything inside
FeedContent, so a load-more button placed after it is the target of
Ctrl+End.
API
<Feed>
The outer container. It owns size and busy. Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
size |
number |
required | Total number of articles in the feed. Published on each article as aria-setsize. |
busy |
boolean |
false |
Whether the feed is loading. Published on FeedContent as aria-busy. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element |
none | The label and content. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<FeedLabel>
Names the feed. FeedContent's aria-labelledby points at it while it is
mounted. Renders a <span> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'span' |
Element or component to render as. |
children |
JSX.Element |
none | The label text. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<FeedContent>
The region that carries role="feed". It handles article navigation and the
Ctrl shortcuts. Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element |
none | The articles. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<FeedArticle>
One article. It is focusable and positioned within the set. Renders an
<article> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'article' |
Element or component to render as. |
index |
number |
required | Zero-based position in the feed. Published as aria-posinset (one-based). |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element |
none | The article's contents. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="article" and tabindex="0". The role is
set even on the default <article>, so as="div" keeps it. aria-labelledby
and aria-describedby are set only while a FeedArticleLabel or
FeedArticleDescription is mounted inside the article.
<FeedArticleLabel>
Names the article. Renders a <span> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'span' |
Element or component to render as. |
children |
JSX.Element |
none | The label text. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<FeedArticleDescription>
Describes the article. Renders a <p> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'p' |
Element or component to render as. |
children |
JSX.Element |
none | The description. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
FeedLabel, FeedContent and FeedArticle throw outside a <Feed>.
FeedArticleLabel and FeedArticleDescription throw outside a <FeedArticle>.