Ana commented on Invoice 42
Looks right to me.
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.
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'; Looks right to me.
The export is ready to download.
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} />| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | 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. |
| items required | array 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. |
| hasMore | boolean | false | 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. |
| loading | boolean | false | More items are being fetched; a loading indicator is shown after the last article and the feed is `aria-busy`. |
| newItemsCount | number | — | 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. |
| headingLevel | 2 | 3 | 4 | 3 | 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. |
| endMessage | string | — | Shown after the last item when `hasMore` is false (and `items` is not empty). Defaults to `copy.end`. |
| Event | Type | Description |
|---|---|---|
| onLoadMore | onLoadMore / 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). |
| onShowNew | onShowNew / 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. |
| onItemVisible | onItemVisible / 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. |
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
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.
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.