Terracotta
GitHub

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>.