Skip to content
kerfjsGitHub

Feed

A long stream of activity, with each event as a knot on a line.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { Feed } from '@kerf-js/veneer'export function Activity() {  return (  <Feed    label="Activity"    items={ACTIVITY}    height="400px"  />  )}type Status =  | 'Live'  | 'Building'  | 'Failed'  | 'Paused'const TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}const DEPLOYS = [  {    id: 'd-4821',    project: 'storefront',    branch: 'main',    commit: '9f3ac21',    message: 'Cache product images at the edge',    author: 'Priya Raman',    status: 'Live',    minutes: 2,    at: '2026-10-31T16:42',  },  {    id: 'd-4820',    project: 'search-api',    branch: 'ranking-v2',    commit: '1be07d4',    message: 'Rank exact matches first',    author: 'Lena Fischer',    status: 'Building',    minutes: 6,    at: '2026-10-31T16:10',  },  {    id: 'd-4819',    project: 'mobile-bff',    branch: 'main',    commit: 'c44e190',    message: 'Retry timed out upstream calls',    author: 'Sam Okafor',    status: 'Failed',    minutes: 4,    at: '2026-10-31T15:27',  },  {    id: 'd-4818',    project: 'checkout',    branch: 'main',    commit: '70d2fa8',    message: 'Add Apple Pay to the last step',    author: 'Tomás Oliveira',    status: 'Live',    minutes: 3,    at: '2026-10-31T14:03',  },  {    id: 'd-4817',    project: 'edge-cache',    branch: 'main',    commit: 'e81b6c2',    message: 'Lower the stale window to 30s',    author: 'Jun Tanaka',    status: 'Live',    minutes: 1,    at: '2026-10-31T11:55',  },  {    id: 'd-4816',    project: 'billing',    branch: 'main',    commit: '2ad9e03',    message: 'Prorate seats on plan change',    author: 'Mara Lindqvist',    status: 'Live',    minutes: 3,    at: '2026-10-29T17:20',  },]const toneOf = (status: string) =>  TONE_OF[status as Status]const verb: Record<string, string> = {  Live: 'shipped',  Building: 'started',  Failed: 'broke',}const hoursAgo = (i: number) =>  i < 24 ? `${i + 1}h` : `${Math.floor(i / 24)}d`const ACTIVITY = Array.from(  { length: 300 },  (_, i) => {    const d = DEPLOYS[i % 6]!    return {      id: i,      what: `${d.author} ${verb[d.status]} ${d.project}`,      when: hoursAgo(i),      detail: `${d.message}. ${d.branch} at ${d.commit}.`,      tone: toneOf(d.status),    }  },)

Installation

npm install @kerf-js/veneer

Try it

Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.

Live preview
Controls
Height
Codeupdates as you change the controls
import { Feed } from '@kerf-js/veneer'<Feed label="Activity" items={activity} height="360px" />

Usage

tsx
import { Feed } from '@kerf-js/veneer'<Feed  label="Activity"  items={ACTIVITY}  height="400px"/>

Loading more

onEnd asks for the next page near the end, and busy tells assistive technology.

tsx
import { state } from '@kerf-js/core'import { Feed, Stack, Text } from '@kerf-js/veneer'export function LoadMore() {  const items = state(ACTIVITY.slice(0, 20))  const busy = state(false)  const more = async () => {    if (items.value.length >= ACTIVITY.length) return    busy.value = true    await new Promise((done) => {      setTimeout(done, 700)    })    items.value = ACTIVITY.slice(      0,      items.value.length + 20,    )    busy.value = false  }  return (    <Stack gap="related">      <Text subtle>        {items.value.length} of {ACTIVITY.length} events        {busy.value ? ', loading more' : ''}.      </Text>      <Feed        label="Activity"        items={items.value}        height="340px"        busy={busy.value}        onEnd={() => void more()}      />    </Stack>  )}type Status =  | 'Live'  | 'Building'  | 'Failed'  | 'Paused'const TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}const DEPLOYS = [  {    id: 'd-4821',    project: 'storefront',    branch: 'main',    commit: '9f3ac21',    message: 'Cache product images at the edge',    author: 'Priya Raman',    status: 'Live',    minutes: 2,    at: '2026-10-31T16:42',  },  {    id: 'd-4820',    project: 'search-api',    branch: 'ranking-v2',    commit: '1be07d4',    message: 'Rank exact matches first',    author: 'Lena Fischer',    status: 'Building',    minutes: 6,    at: '2026-10-31T16:10',  },  {    id: 'd-4819',    project: 'mobile-bff',    branch: 'main',    commit: 'c44e190',    message: 'Retry timed out upstream calls',    author: 'Sam Okafor',    status: 'Failed',    minutes: 4,    at: '2026-10-31T15:27',  },  {    id: 'd-4818',    project: 'checkout',    branch: 'main',    commit: '70d2fa8',    message: 'Add Apple Pay to the last step',    author: 'Tomás Oliveira',    status: 'Live',    minutes: 3,    at: '2026-10-31T14:03',  },  {    id: 'd-4817',    project: 'edge-cache',    branch: 'main',    commit: 'e81b6c2',    message: 'Lower the stale window to 30s',    author: 'Jun Tanaka',    status: 'Live',    minutes: 1,    at: '2026-10-31T11:55',  },  {    id: 'd-4816',    project: 'billing',    branch: 'main',    commit: '2ad9e03',    message: 'Prorate seats on plan change',    author: 'Mara Lindqvist',    status: 'Live',    minutes: 3,    at: '2026-10-29T17:20',  },]const toneOf = (status: string) =>  TONE_OF[status as Status]const verb: Record<string, string> = {  Live: 'shipped',  Building: 'started',  Failed: 'broke',}const hoursAgo = (i: number) =>  i < 24 ? `${i + 1}h` : `${Math.floor(i / 24)}d`const ACTIVITY = Array.from(  { length: 300 },  (_, i) => {    const d = DEPLOYS[i % 6]!    return {      id: i,      what: `${d.author} ${verb[d.status]} ${d.project}`,      when: hoursAgo(i),      detail: `${d.message}. ${d.branch} at ${d.commit}.`,      tone: toneOf(d.status),    }  },)

Your own item

children draws the whole item, here with a coloured knot and a badge. Keep the data-parts and it stays on the thread.

tsx
import { Badge, Feed } from '@kerf-js/veneer'export function Custom() {  return (  <Feed    label="Deploys"    items={ACTIVITY}    height="340px"  >    {(item) => (      <>        <span          data-part="knot"          style={            `--_dc:var(--veneer-color-` +            `${item().tone}-solid)`          }        ></span>        <span data-part="what">{item().what}</span>        <Badge tone={item().tone}>{item().when}</Badge>        <span          data-part="detail"          data-measure=""        >          {item().detail}        </span>      </>    )}  </Feed>  )}type Status =  | 'Live'  | 'Building'  | 'Failed'  | 'Paused'const TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}const DEPLOYS = [  {    id: 'd-4821',    project: 'storefront',    branch: 'main',    commit: '9f3ac21',    message: 'Cache product images at the edge',    author: 'Priya Raman',    status: 'Live',    minutes: 2,    at: '2026-10-31T16:42',  },  {    id: 'd-4820',    project: 'search-api',    branch: 'ranking-v2',    commit: '1be07d4',    message: 'Rank exact matches first',    author: 'Lena Fischer',    status: 'Building',    minutes: 6,    at: '2026-10-31T16:10',  },  {    id: 'd-4819',    project: 'mobile-bff',    branch: 'main',    commit: 'c44e190',    message: 'Retry timed out upstream calls',    author: 'Sam Okafor',    status: 'Failed',    minutes: 4,    at: '2026-10-31T15:27',  },  {    id: 'd-4818',    project: 'checkout',    branch: 'main',    commit: '70d2fa8',    message: 'Add Apple Pay to the last step',    author: 'Tomás Oliveira',    status: 'Live',    minutes: 3,    at: '2026-10-31T14:03',  },  {    id: 'd-4817',    project: 'edge-cache',    branch: 'main',    commit: 'e81b6c2',    message: 'Lower the stale window to 30s',    author: 'Jun Tanaka',    status: 'Live',    minutes: 1,    at: '2026-10-31T11:55',  },  {    id: 'd-4816',    project: 'billing',    branch: 'main',    commit: '2ad9e03',    message: 'Prorate seats on plan change',    author: 'Mara Lindqvist',    status: 'Live',    minutes: 3,    at: '2026-10-29T17:20',  },]const toneOf = (status: string) =>  TONE_OF[status as Status]const verb: Record<string, string> = {  Live: 'shipped',  Building: 'started',  Failed: 'broke',}const hoursAgo = (i: number) =>  i < 24 ? `${i + 1}h` : `${Math.floor(i / 24)}d`const ACTIVITY = Array.from(  { length: 300 },  (_, i) => {    const d = DEPLOYS[i % 6]!    return {      id: i,      what: `${d.author} ${verb[d.status]} ${d.project}`,      when: hoursAgo(i),      detail: `${d.message}. ${d.branch} at ${d.commit}.`,      tone: toneOf(d.status),    }  },)

When to use

Use it for

  • Audit trails and activity on a project
  • An endless stream that loads as you scroll

Reach for something else

  • VirtualList, when the rows are not events in time
  • Table, when people sort and compare

Accessibility

PageDown
Moves to the next event
PageUp
Moves to the previous event
↑↓
Moves between events
HomeorEnd
Goes to the first or last event
  • It follows the APG feed pattern: each event is an article inside a feed.

API Reference

PropTypeDefaultDescription
itemsreadonly T[]—Each with id and what, and optional when, detail and tone.
labelstring—Its accessible name. Fields show it; icon-only controls speak it.
heightstring'480px'The scroller's height. Default 480px.
busybooleanfalseMore is loading.
control((c: VirtualControl) => void)—Hands over a control to scroll to, focus or move between items from outside.
children((item: () => T) => unknown)—An item's whole content. Default: knot, what, when, detail.
EventTypeDefaultDescription
onEnd(() => void)—Called near the end, to load more.
onDrift((index: number, predicted: number, actual: number) => void)—Called when an item's measured height differs from the estimate, with both heights. For tuning.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="feed"] [data-part="knot"]

knotwhatwhendetail

RelatedVirtual listActivity feedTable