Activity Feed

A chronological timeline of events — commits, comments, deployments, alerts, and membership changes. Supports filtering, unread indicators, and infinite scroll.

Preview

  1. Jamie R deployed v1.3.0 → production

  2. Sam T commented on PR #214 — Grid system

  3. Alex K merged feat/density-tokens

  4. System error spike detected API — /v2/export

  5. Mia C joined Design Systems team

  6. Jamie R pushed 3 commits to main

Anatomy

PartElementDescription
Container<ol>Ordered list — events have temporal order. aria-label='Activity feed'.
ConnectordivVertical line between items. aria-hidden='true' — purely decorative.
AvatarspanType-coloured circle with user initials. aria-hidden — user name is in the text.
Bodyp + timeActor + action + target sentence. <time> element for the timestamp.
Unread dotspanSolid 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.