Skip to content
Component installs need the registry setup
VegaStack Design

Activity Feed

A record's history, Linear style — comment threads and change events in one list, one actor's runs on one line, a filter and J/K navigation.

Status
stable
Since
0.23.123
Accessibility pattern
labelled section, ordered list, J/K keyboard navigation

Last updated

Activity 2 comments

  1. created the task

  2. set priority to High; set the due date to Oct 7; attached scope-v2.pdf

    • PS

      Customer wants the revised scope before we start. Can we split the migration into two phases?

    • KN

      Yes — phase one is the data copy, phase two the cut-over.

    AR


  3. assigned Kavya Nair

  4. changed status from Todo to In progress

  5. Agent moved the due date from Oct 7 to Oct 9

    • Agent

      Summary so far: scope split into two phases, due date moved to Oct 9, Kavya owns phase one.

    AR



Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Activity Feed before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify activity-feed

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/activity-feed

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/button, @vegastack/person-avatar, @vegastack/person-hover-card, @vegastack/relative-time, @vegastack/searchable-select, @vegastack/select, @vegastack/skeleton.

Usage

import {
  ActivityFeed,
  ActivityFeedItem,
  ActivityFeedList,
} from "@/components/ui/activity-feed";

<ActivityFeed>
  <ActivityFeedList>
    <ActivityFeedItem>{event}</ActivityFeedItem>
  </ActivityFeedList>
</ActivityFeed>;

Scope

  • Owns the activity list, event presentation, filtering and order controls, and keyboard navigation.
  • The host owns data fetching, merging, paging, filter/order state, and read acknowledgements.
  • Compose with CommentThread, CommentComposer, and CommentDayDivider from comments.

Anatomy

ActivityFeed — data-slot="activity-feed" | "activity-feed-count" | "activity-feed-filter" | "activity-feed-header" | "activity-feed-more" | "activity-feed-more-skeleton" | "activity-feed-order"
ActivityFeedList — data-slot="activity-feed-list"
ActivityFeedItem — data-slot="activity-feed-item"
ActivityEvent — data-slot="activity-event" | "activity-event-icon"
ActivityValue — data-slot="activity-value"
ActivityEventGroup — data-slot="activity-change" | "activity-event-group" | "activity-event-group-toggle" | "activity-run-changes" | "activity-run-toggle"
ActivityKindIcon — data-slot="activity-kind-icon"
ActivityUnreadDivider — data-slot="activity-unread-divider"
ActivityJumpToLatest — data-slot="activity-jump-floating"
ActivityFeedSkeleton — data-slot="activity-feed-skeleton"

Examples

Comments and events

import {
  ActivityEvent,
  ActivityFeed,
  ActivityFeedItem,
  ActivityFeedList,
  ActivityJumpToLatest,
  ActivityKindIcon,
  ActivityUnreadDivider,
  ActivityValue,
  useActivityFeedKeyboard,
} from "@/components/ui/activity-feed";
import { CommentComposer, CommentThread } from "@/components/ui/comments";

const feedRef = useActivityFeedKeyboard();

<ActivityFeed
  ref={feedRef}
  count={threads.length}
  filter={filter}
  onFilterChange={setFilter}
  order={order}
  onOrderChange={setOrder}
>
  <ActivityFeedList>
    {items.map((item) =>
      item.type === "event" ? (
        <ActivityFeedItem key={item.id}>
          <ActivityEvent
            actor={item.actor}
            icon={<ActivityKindIcon kind={item.kind} />}
            date={item.at}
          >
            changed status to <ActivityValue>{item.to}</ActivityValue>
          </ActivityEvent>
        </ActivityFeedItem>
      ) : (
        <ActivityFeedItem key={item.id} kind="thread">
          <CommentThread thread={item.thread} viewer={me} onReply={reply} />
        </ActivityFeedItem>
      ),
    )}
  </ActivityFeedList>
  <CommentComposer onSubmit={post} />
  <ActivityJumpToLatest target={latestBoundary} />
</ActivityFeed>;

The parts are presentational and data-agnostic: the host merges its comment threads and its change events into one list, applies the filter and the order, and pages them. Events sit 4px apart; a thread card or a divider keeps 12px around it. Each event leads with a 20px node in the same column as the comments' avatars — an ActivityKindIcon, the new value's StatusIcon or PriorityIcon, or the actor's avatar (icon="avatar") — then the actor, whose name opens their person hover card, the sentence with its ActivityValues, and the relative time.

Put CommentDayDivider (from comments) between items on different days, and one ActivityUnreadDivider before the first item the viewer hasn't seen; its onVisible fires once, when the line is half on screen. The host chooses whether observing that line acknowledges anything. ActivityJumpToLatest takes the host's latest-content boundary. It observes that element in its scrolling container, points toward it, and transfers focus after reaching it. It floats only while that boundary is off screen and the reader is scrolling, and fades about 1.5 seconds after they settle; hovering or focusing it holds it in place. forceVisible keeps a catch-up action available for content already on screen, and turning it on surfaces the control until the reader settles; capture a batch in onJump and acknowledge it in onReached. Replacing the target or interrupting with a gesture cancels an in-progress completion and calls onCancel, allowing the host to release its jump state. The control contains no read-state or data logic. The latest items are last in oldest-first order and first in newest-first order. The arrow points toward that boundary. Set direction="up" for newest-first or direction="down" for oldest-first when an on-screen batch keeps the action visible. An agent event says "Agent" after the actor's name.

Activity 2 comments

  1. created the task

  2. set priority to High; set the due date to Oct 7; attached scope-v2.pdf

    • PS

      Customer wants the revised scope before we start. Can we split the migration into two phases?

    • KN

      Yes — phase one is the data copy, phase two the cut-over.

    AR


  3. assigned Kavya Nair

  4. changed status from Todo to In progress

  5. Agent moved the due date from Oct 7 to Oct 9

    • Agent

      Summary so far: scope split into two phases, due date moved to Oct 9, Kavya owns phase one.

    AR



Event kinds

ActivityKindIcon draws a 14px muted icon for twenty-eight common kinds; approved, rejected, requested and the agent-run kinds keep their status colour.

  1. created the task

  2. edited the description

  3. renamed the task to Phase one: data copy

  4. marked the task Done

  5. set priority to Medium

  6. assigned Kavya Nair

  7. unassigned Kavya Nair

  8. set the due date to Oct 9

  9. moved the task to Migrations

  10. attached scope-v2.pdf

  11. removed scope-v1.pdf

  12. linked INF-212

  13. unlinked INF-198

  14. commented

  15. archived the task

  16. restored the task

  17. deleted the subtask Draft runbook

  18. approved the request

  19. rejected the request

  20. requested approval from Asha Rao

Status and priority values

A status or priority change leads with the new value's own icon. Pass label="" — the sentence already names the value.

  1. changed status to Backlog

  2. changed status to Todo

  3. changed status to In progress

  4. changed status to In review

  5. changed status to Done

  6. changed status to Canceled

  7. changed status to Duplicate

  8. changed status to Triage

  9. set priority to Urgent

  10. set priority to High

  11. set priority to Medium

  12. set priority to Low

  13. set priority to No priority

Event groups

Pass pre-grouped events to ActivityEventGroup for one compact entry: the actor and latest timestamp appear once, every change stays visible in chronological order, and the sentence wraps under its text column. The host bounds a burst (two minutes, say), keeps actor, comment/reply, calendar-day and unread boundaries, and groups before reversing the entry order. Server comment-boundary markers let hidden replies separate edits too. Supply each event's stable id so its position survives joining a group across cursor pages.

The children-based form remains available for explicitly folded rows (visible and defaultOpen).

  1. renamed the task to Migrate billing data; edited the description; linked INF-212

  2. renamed the task to Migrate billing data

    edited the description

    linked INF-212

Runs

groupActivityRuns(items, { actorKey, date, isBreak, breakBefore, runWindowMs }) splits a chronological list into runs: one actor's consecutive events within runWindowMs (10 minutes by default) of the run's first, ended by any isBreak item (a comment) or the host's own breakBefore (a new day, the unread line). Render each run with ActivityEventGroup. Give events a change — { field, verb?, from?, to } — and a run that changes one field reads as one chain: "moved it Todo → In progress → In review". Set summarizeAt={3} and a run of three or more different changes reads "made 3 changes" (or summarizeRun's words), a disclosure that lists them under the line. Agents' bursts read as one line this way.

const runs = groupActivityRuns(items, {
  actorKey: (item) => item.actorId,
  date: (item) => item.createdAt,
  isBreak: (item) => item.type === "comment",
});

{
  runs.map((run) => (
    <ActivityFeedItem key={run[0].id}>
      <ActivityEventGroup events={run.map(toEventProps)} summarizeAt={3} />
    </ActivityFeedItem>
  ));
}
  1. Agent moved it Todo to In progress to In review

Cursor pagination

Pass loadMore={{ hasMore, onLoadMore, loading, error }} and a paginationKey containing the filter, list generation and next cursor to ActivityFeed. The history edge loads automatically when it enters view: above the list oldest first, below it newest first. Loading older items preserves the current visible change in both orders, including when you scroll during the request or the page joins an existing group. While a page loads, skeleton event rows hold its place. Set loadMore.error after a failed read; automatic retries stop and a Retry button appears. loadingMoreLabel and retryMoreLabel override the pagination copy. Keep an unread divider within the same timeline rather than paginating unread items separately.

Agents and the system

An actor (or comment author) with kind: "agent" and via — "Claude Code" working for Priya — is drawn as the purple bot and named "Claude Code via Priya" with the "Agent" label; a comment gets a violet bar. (The agent boolean on an event or a comment is the deprecated spelling of the same and goes away next release.) An event with actor={null} is the system's. An actor's badge (a string such as "Inactive", for someone deactivated or whose invite is pending) follows their name as a small muted outline badge, as everywhere a person is named.

  1. Agent triaged the task into Migrations

    • Agent

      Summary so far: scope split into two phases, due date moved to Oct 9, Kavya owns phase one.

  2. Inactive changed status to In review

  3. System marked the task overdue

Agent runs

delegated and undelegated mark a member handing a record to their agent and taking it back; agent-queued, agent-working, agent-needs-you, agent-done and agent-failed are the agent's run in its status tones (working is a static play mark — a history line never spins); agent-step is one server-written step of the run.

  1. delegated this to Claude Code (via Priya)

  2. Agent picked this up

  3. Agent started working

  4. Agent read the task and 3 linked pages

  5. Agent updated the description

  6. Agent needs Priya

  7. Agent finished

  8. Agent stopped: hit a problem

  9. removed Claude Code (via Priya)

Comments only

The filter's Comments option: the host shows the threads alone.

Activity 2 comments

    • PS

      Customer wants the revised scope before we start. Can we split the migration into two phases?

    • KN

      Yes — phase one is the data copy, phase two the cut-over.

    AR


    • Agent

      Summary so far: scope split into two phases, due date moved to Oct 9, Kavya owns phase one.

    AR



Activity only

The filter's Activity option: the events alone.

Activity 2 comments

  1. created the task

  2. set priority to High; set the due date to Oct 7; attached scope-v2.pdf

  3. assigned Kavya Nair

  4. changed status from Todo to In progress

  5. Agent moved the due date from Oct 7 to Oct 9


Loading

ActivityFeedSkeleton holds the feed's shape — event rows, a thread card and the composer — while it loads. Set pending on ActivityFeed instead while a new filter or order loads: the old items stay, dimmed.

Activity 2 comments

Empty

A record with no history yet shows the header and the composer.

Activity


API Reference

PropTypeDefaultDescription
countnumber—The number of comments, after the heading through countLabel; hidden at zero.
countLabel((count: number) => string)(n) => "1 comment" | "n comments"The count's words, so the number says what it counts ("Activity · 2 comments").
filterActivityFilter"all"The filter shown; the filter menu shows when onFilterChange is set.
loadingMoreLabelstring"Loading earlier…"Progress copy.
loadMore{ hasMore: boolean; onLoadMore: () => Promise<void> | void; loading?: boolean; error?: boolean; }—Cursor pagination, using the collection paging contract.
onFilterChange((filter: ActivityFilter) => void)—Called from the filter menu.
onOrderChange((order: ActivityOrder) => void)—Called from the Oldest / Newest first toggle.
orderActivityOrder"oldest"The order shown; the toggle shows when onOrderChange is set.
paginationKeystring | null—Filter, list generation and cursor identity; changes re-arm loading.
pendingbooleanfalseDim the list while a new filter or order loads (the old items stay).
retryMoreLabelstring"Retry loading earlier"Retry action copy.
titlestring"Activity"The heading (an h2).

Data attributes and CSS variables on ActivityFeed

AttributeValues
data-pending""
data-slot"activity-feed" | "activity-feed-count" | "activity-feed-filter" | "activity-feed-header" | "activity-feed-more" | "activity-feed-more-skeleton" | "activity-feed-order"

ActivityFeedItem

PropTypeDefaultDescription
kind"divider" | "event" | "thread""event"What the row holds — its spacing follows: event rows sit 4px apart, a thread card or a divider keeps 12px around it.

Data attributes and CSS variables on ActivityFeedItem

AttributeValues
data-kindmirrors a prop or state value
data-slot"activity-feed-item"

ActivityEvent

PropTypeDefaultDescription
actor*Person | null—Who did it; null for the system. A badge on the person ("Inactive") follows the name.
children*React.ReactNode—What happened, after the actor's name — plain words with ActivityValues.
date*string | number | Date—When it happened.
agentbooleanfalseDeprecated.
changeActivityChange—The field this event changed, for ActivityEventGroup's run summary; the event alone still reads its children.
classNamestring—Classes for the row.
iconReact.ReactNodea muted dotThe 20px node before the text — an ActivityKindIcon, a StatusIcon, a PriorityIcon. "avatar" draws the actor's avatar (an assignee change, say).
idstring—Stable event identity for preserving position across cursor-page joins.
nownumber—Pin the clock (docs, tests).

Data attributes and CSS variables on ActivityEvent

AttributeValues
data-activity-idmirrors a prop or state value
data-agent""
data-slot"activity-event" | "activity-event-icon"

ActivityEventGroup

PropTypeDefaultDescription
changesLabel((count: number) => string)(n) => "made n changes"The folded line's words.
childrenReact.ReactNode—Event rows, when not using events.
classNamestring—Classes for the group.
defaultOpenbooleanfalseStart unfolded.
eventsreadonly ActivityEventProps[]—One run of events, in chronological order — one actor, close together (groupActivityRuns finds them). It reads as one line with the latest time: a run that changes one change.field is a chain ("moved it Todo → In progress → In review"); other runs list every change joined by "; ", or — from summarizeAt changes — read "made 3 changes" and expand to list them.
summarizeAtnumber—Fold a run of different changes this long or longer (at least 2) into one expandable line ("made 3 changes"); unset, every change stays listed. 3 is the house setting.
summarizeRun((events: readonly ActivityEventProps[]) => React.ReactNode)—The words for a run of different changes, after the actor ("updated the brief"), instead of changesLabel; it still expands to list them. Return null for the default.
visiblenumber1Events shown while folded (the children form).

Data attributes and CSS variables on ActivityEventGroup

AttributeValues
data-activity-idmirrors a prop or state value
data-activity-idsmirrors a prop or state value
data-layout"chain" | "combined" | "run"
data-open""
data-slot"activity-change" | "activity-event-group" | "activity-event-group-toggle" | "activity-run-changes" | "activity-run-toggle"

ActivityJumpToLatest

PropTypeDefaultDescription
target*HTMLElement | null—The latest content's focusable boundary, supplied by the host.
classNamestring—Classes for the floating row.
direction"down" | "up"—The selected feed direction; when omitted, points toward the observed boundary.
enabledbooleantrueEnable the control.
forceVisiblebooleanfalseKeep the action available for a new batch already on screen; turning it on surfaces the control until the reader settles.
labelstring"Jump to latest"The button's label.
onCancel((target: HTMLElement) => void)—Called when user interaction or a changed boundary cancels a jump.
onJump((target: HTMLElement) => void)—Called at activation; capture the batch here.
onReached((target: HTMLElement) => void)—Called once after that same boundary is reached.

Data attributes and CSS variables on ActivityJumpToLatest

AttributeValues
data-shown""
data-slot"activity-jump-floating"

Accessibility

  • The feed is a section labelled by its heading; the items are an ordered list named "Activity".
  • The filter is a select named "Show"; the order toggle is a button whose label names the order shown.
  • An event is one sentence: the event's icon is decorative, the actor's name is a button that opens their hover card, and the relative time shows the exact time on hover or focus.
  • Combined event groups keep every change visible and announce the actor and time once. A chain's arrows read as "to". A folded run ("made 3 changes") and the children-based disclosure are buttons with aria-expanded; the folded run's button names the list it controls.
  • The heading's count says what it counts through countLabel ("Activity 2 comments").
  • The unread line is a separator named "New".
  • "Jump to latest" is hidden from assistive technology and out of the tab order while it is not shown; activating it moves focus to the "Latest activity" spot it scrolled to.
  • J and K never fire while focus is in a field, a menu, a listbox or a dialog, with a modifier held, or when something else already handled the key. With several feeds on a page, the one holding focus answers (else the one mounted last); X ignores an item that has left the list.
  • Scrolling (J/K, Jump to latest) is instant when the viewer prefers reduced motion.
KeyAction
JMove to the next item (tinted and scrolled into view)
KMove to the previous item
XToggle the focused item (onToggle — unfold its replies)
EscClear the focused item
ContractStates tested
Behaviourfilter, order, pending, event, system-actor, agent-actor, group-folded, group-open, unread, jump-to-latest, keyboard-focused, loading, explicit-jump-target, captured-jump-completion, run-chain, run-folded, run-expanded
Accessibilitylabeled, semantic-html, keyboard-navigation, browser-accessibility-test
Visualdefault, loading, focused

Do / Don't

Do
Write each event as a short sentence after the actor's name, with the values in ActivityValue.
Don't
Show every keystroke as its own event — fold a run of quick edits into an ActivityEventGroup.
Do
Capture the current batch in onJump, then acknowledge only that batch in onReached.
Don't
Acknowledge unseen activity when the control is clicked or an unread divider becomes visible.

On this page