Activity Feed
A chronological timeline of events — commits, comments, deployments, alerts, and membership changes. Supports filtering, unread indicators, and infinite scroll.
Preview
Jamie R deployed v1.3.0 → production
Sam T commented on PR #214 — Grid system
Alex K merged feat/density-tokens
System error spike detected API — /v2/export
Mia C joined Design Systems team
Jamie R pushed 3 commits to main
Anatomy
| Part | Element | Description |
|---|---|---|
| Container | <ol> | Ordered list — events have temporal order. aria-label='Activity feed'. |
| Connector | div | Vertical line between items. aria-hidden='true' — purely decorative. |
| Avatar | span | Type-coloured circle with user initials. aria-hidden — user name is in the text. |
| Body | p + time | Actor + action + target sentence. <time> element for the timestamp. |
| Unread dot | span | Solid accent dot with aria-label='Unread'. |
Filtering
Filter chips above the feed allow users to isolate event types. Filters are additive — selecting multiple types shows the union. The "all" chip clears all active filters. Apply filters client-side for local lists; use query parameters for server-paginated feeds so filtered URLs are shareable.
Implementation
ActivityFeed.tsx
tsx
interface FeedItem {
id: string;
user: string;
avatar: string;
action: string;
target: string;
time: string;
type: "commit" | "comment" | "deploy" | "alert" | "member";
unread?: boolean;
}
function ActivityFeed({ items }: { items: FeedItem[] }) {
return (
<ol
aria-label="Activity feed"
className="relative flex flex-col"
>
{/* Vertical connector line */}
<div
aria-hidden="true"
className="absolute inset-y-0 left-[19px] w-px bg-[rgb(var(--border))]"
/>
{items.map((item) => (
<li key={item.id} className="relative flex gap-4 pb-6 last:pb-0">
{/* Type dot */}
<span
className={`
relative z-10 flex h-10 w-10 shrink-0 items-center justify-center
rounded-full text-[11px] font-bold text-white
${typeStyles[item.type]}
`}
aria-hidden="true"
>
{item.avatar}
</span>
<div className="flex-1 pt-1.5">
<p className="text-[13px] text-[rgb(var(--text-secondary))]">
<strong className="font-medium text-[rgb(var(--text-primary))]">
{item.user}
</strong>{" "}
{item.action}{" "}
<span className="font-medium text-[rgb(var(--text-primary))]">
{item.target}
</span>
</p>
<time className="text-[12px] text-[rgb(var(--text-tertiary))]">
{item.time}
</time>
</div>
{item.unread && (
<span
className="mt-2 h-2 w-2 shrink-0 rounded-full bg-[rgb(var(--accent))]"
aria-label="Unread"
/>
)}
</li>
))}
</ol>
);
}Guidelines
- →Write each event as a complete sentence: Actor + verb + target. Avoid terse tokens like 'push' with no context.
- →Use relative time (2m ago) for events within the last 24 hours; switch to absolute dates for older events.
- →Color-code by event type, not by user — users scanning for deploys should be able to spot the type at a glance.
- →Paginate or virtualise for lists exceeding 100 items — DOM and accessibility tree overhead is significant.
- →Mark events as read on visibility (Intersection Observer) or on explicit 'Mark all read' action — not on hover.