Feed

A scrolling list of articles that loads more as the reader reaches the end — an activity stream, notifications, a timeline — with the APG feed keyboard model so screen-reader users can move article by article and load more without losing their place.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Feed } from '@design-schema/react';

Examples

Ana commented on Invoice 42

unread1 of 3

Looks right to me.

Cy exported the March report

3 of 3

The export is ready to download.

You are all caught up.

import { Button, Feed, type FeedItem, Link, Text } from '@design-schema/react';

const SAMPLE_ITEMS: FeedItem[] = [
  {
    id: 'a1',
    heading: 'Ana commented on Invoice 42',
    timestamp: '2026-09-15T09:00:00Z',
    content: <Text>Looks right to me.</Text>,
    actions: <Button label="Reply" variant="secondary" size="sm" />,
    unread: true,
  },
  {
    id: 'a2',
    heading: 'Bo approved Invoice 41',
    timestamp: '2026-09-14T16:20:00Z',
    content: <Link href="#invoice-41" label="Open Invoice 41" />,
  },
  {
    id: 'a3',
    heading: 'Cy exported the March report',
    timestamp: '2026-09-10T12:00:00Z',
    content: <Text>The export is ready to download.</Text>,
    actions: <Button label="Download" variant="secondary" size="sm" />,
  },
];

<Feed label="Activity" items={SAMPLE_ITEMS} />

Props

7 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

What the feed contains ("Activity", "Notifications"). There is no default; an empty or whitespace-only label warns once per mount in development, with the wording "Feed: label is the accessible name of the feed and must not be empty."

Accessibility: aria-label on the feed.

web, lit, rn, swiftui
items requiredarray FeedItem[] where FeedItem = { id: string; heading: string; timestamp: string; content: ReactNode; actions?: ReactNode; unread?: boolean }—

Articles, newest first. `heading` names the article (a Heading inside the Card); `timestamp` is ISO and rendered relative from the copy strings (`justNow` under a minute, `minutesAgo` under an hour, `hoursAgo` under a day, `daysAgo` under seven days — strictly under, so at exactly seven days the absolute date shows — else the absolute date from Intl.DateTimeFormat in the user's locale) with the absolute time as its title on web (native shows the relative string only); `unread` marks items the user has not seen. Relative counts are floored (90 seconds is `1 min ago`); a future timestamp shows `justNow`; the absolute date is `Intl.DateTimeFormat(undefined, { dateStyle: 'medium' })` and the title adds `timeStyle: 'short'`; an unparseable timestamp is shown as given with no title. Relative text is computed at render and does not tick. On React Native the component wraps string or number `content` in the package `Text` (as Disclosure does); other content renders as given.

web, lit, rn, swiftui
hasMorebooleanfalse

More items exist beyond the last; the feed asks for them with `onLoadMore` as the end approaches, and whenever `items` is empty and not `loading` — on mount and again if the caller clears `items` — so an empty feed fetches its first page itself. While the last article stays in view it asks at most once per change of the last item's id, `hasMore` or `loading`, and never while `loading`; a prepend from `onShowNew` leaves the last id unchanged, so it does not ask again. A `loading` cycle that ends with the same last id re-arms the request, so a page that failed can be asked for again while the reader is still at the end.

web, lit, rn, swiftui
loadingbooleanfalse

More items are being fetched; a loading indicator is shown after the last article and the feed is `aria-busy`.

web, lit, rn, swiftui
newItemsCountnumber—

Number of newer items available above (from polling or a socket). The feed does not insert them — that would shift what the reader is looking at — it shows a "Show {count} new" button at the top which prepends and scrolls. Undefined, zero or a negative count shows no button; the live row stays in the DOM unpadded so the count is announced the moment it first appears. A fractional count is the caller's error, not the component's to repair: it is interpolated into `copy.showNew` as given, with no truncation.

web, lit, rn, swiftui
headingLevel2 | 3 | 43

Heading level for article headings, matching the page outline. React Native has no heading levels and Card renders every article heading at one size, so the level is passed to Card for parity and changes nothing a user or a test can see there.

web, lit, rn, swiftui
endMessagestring—

Shown after the last item when `hasMore` is false (and `items` is not empty). Defaults to `copy.end`.

web, lit, rn, swiftui

Events

3 rows
EventTypeDescriptionPlatforms
onLoadMoreonLoadMore / load-more / onEndReached

Fired when the last rendered article is within one screen of view (or on Ctrl+End with `hasMore` and not `loading`; plain End is not a feed command).

web, lit, rn, swiftui
onShowNewonShowNew / show-new

Fired when the new-items button is pressed; the caller prepends the items and clears `newItemsCount`. Focus moves to the first article once the first item's id changes; if nothing is prepended, focus stays on the button. The pending focus request lives until the next change of `items` — a change of the item ids in order, not of the array's identity, so a caller that re-renders a fresh array with the same ids keeps the request — and no further: that change moves focus when it puts a new id first, and otherwise drops the request, so an unrelated later prepend never steals focus. The move is web, Lit and SwiftUI only — nothing can focus a View by script on iOS or Android, so React Native relies on `maintainVisibleContentPosition` and leaves focus on the button.

web, lit, rn, swiftui
onItemVisibleonItemVisible / item-visible / onViewableItemsChanged

Fired with an item id when it has been substantially visible for a moment (mark as read). Fires once per item id per mount; an item that scrolls out and back does not fire again, nor does an id removed from `items` and added back. The dedupe is the component's, not the observer's: React Native applies it on top of `onViewableItemsChanged`, which re-fires on every re-entry.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Choose the heading level so it follows the heading above it on your page. The component will not guess the outline it lands in.

Required props that carry accessibility meaning: label. Each is described in the props table above.

Implicit ARIA role: feed. React Native renders role list.

Anatomy

containerarticletimestamparticleBodyarticleActionsloadingIndicatorendMessagenewItemsButtonemptyState

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Heading hierarchy — Headings this component renders take their level from the caller, so the page outline never skips a level.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Live region — Changes are announced through a live region, without moving focus.

Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.

Feed keyboard support

5 rows
KeyAction
TabMoves through interactive content inside the current article and on to the next article's content in reading order; articles are focusable but never tab stops (Card `focusable` renders `tabindex="-1"`, so a hundred cards are not a hundred stops), and focus reaches one by pointer, by Ctrl+Home/End returning, or through a screen reader's browse mode — the feed commands below act from there.
PageDownMoves focus to the next article (the APG feed command).
PageUpMoves focus to the previous article.
Control+EndMoves focus to the first focusable element after the feed in the document; with `hasMore`, instead triggers a load, or does nothing while `loading` (press again once it has loaded and `hasMore` is false). Feed commands act only from inside an article, not from the new-items button. The key is always consumed there, the `loading` case included, so a feed command never falls through to the browser's scroll to the end of the document.
Control+HomeMoves focus to the new-items button when shown, else to the last focusable element before the feed in the document.

Contrast pairs

3 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA
color.control.selectedBackgroundcolor.backgroundAA (non-text)

Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.