Display

Activity Feed

A grouped activity stream — consecutive entries that share a verb and a target fold into one line, with day separators, an unread boundary and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronDown, Inbox } from "lucide-react"
import { cn } from "@/lib/utils"
import type {
  ActivityFeedActor,
  ActivityFeedData,
  ActivityFeedItem,
  ActivityFeedTarget,
} from "./activity-feed.contract"

const DEFAULT_LOCALE = "en-US"
/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/activity-feed.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ActivityFeed" component with zod and
lucide-react. No animation library, no timers, no observers.

Contract
- A zod schema (`activityFeedItemSchema` in a sibling contract file) is the
  single source of truth for one entry: { id, actor: { id, name, avatarUrl?,
  href? }, verb, target: { id?, label, type?, href? }, at (ISO 8601), meta? }.
  Props are z.infer of it — never a parallel hand-written interface.
- `actor.id`, not the name, decides "the same person" when entries fold: two
  people called Alex Chen must stay two faces. `target.id` falls back to
  `target.label`, so a renamed target reads as a new thing, which it is.
- `verb` is the sentence's verb and must be PAST TENSE ("commented on",
  "merged"). English past tense is number-invariant, which is what lets one
  actor and five actors share one string. Machine keys ("issue.comment") are
  accepted: `verbLabels` maps them, otherwise they are humanised.
- A separate status union — "loading" | "empty" | "error" | "ready" — is the
  feed's own render state, independent of any entry.
- Props: items: ActivityFeedItem[]; status; now: string|number|Date (REQUIRED);
  lastReadAt?; order? ("newest-first"); collapse? ("verb-target"|"actor-verb"|
  "none"); groupWithinMs? (6h); timeZone? ("UTC"); locale? ("en-US");
  maxVisible? (8); onLoadMore?; onMarkRead?; verbLabels?; renderSummary?;
  labels?: Partial<ActivityFeedLabels>; showMeta? (true); defaultExpandedIds?;
  errorMessage?; onRetry?; emptyState?; skeletonRows? (4); label? ("Activity");
  className. Spread the rest onto the root div and forwardRef it.
- `now` is required ON PURPOSE: the component must never call new Date() during
  render (impure, and it makes the server and the client disagree about which
  day an entry belongs to). timeZone defaults to "UTC" for the same reason —
  day bucketing decides which separator ROWS exist, so a zone mismatch changes
  the structure of the list, not just a string.
- `items` order does not matter — the component sorts. It de-dupes by id first,
  because two rows sharing an id share a React key AND the expand-state key.
- maxVisible / skeletonRows are clamped to >= 1; a 0 or NaN would render nothing
  and leave a footer button with no rows above it.

Behavior
- Four first-class branches: loading → skeleton rows with the same avatar column
  and two-line rhythm as a real row (nothing jumps when data lands), aria-busy
  on the root and one sr-only role="status"; empty → icon + message, replaceable
  via `emptyState`; error → role="alert" with the message and a "Try again" ONLY
  when `onRetry` is passed; ready → the grouped stream. status="ready" with zero
  rows falls through to the empty body.
- Folding is RUN-LENGTH, never global. After sorting, an entry merges into the
  row above it only when ALL of these hold: same collapse key; same civil day;
  same side of the unread boundary; and the gap to the row's last entry is
  <= groupWithinMs. Undated entries never merge. The collapse key is
  `verb + target identity` by default, `actor.id + verb` under "actor-verb", and
  nothing at all under "none". A feed that merged non-consecutive entries would
  be reordering history to make its sentences shorter.
- The sentence names up to two actors and turns the tail into "N others", joined
  with Intl.ListFormat().formatToParts() so the separator ("and", "、", " y ")
  comes from the locale while each name can still be a link. Under "actor-verb"
  the aggregate moves to the object instead: "Ada assigned issue Flaky billing
  test and 3 more". Everything past the first two names is a phrase, not a
  person, so it never gets a profile link.
- Time is rendered twice: a relative label for scanning (coarsest-fit buckets
  from seconds to years via Intl.RelativeTimeFormat with numeric:"auto") and the
  absolute instant WITH its zone name, carried in both `title` and an sr-only
  span, because `title` alone is announced by almost nothing. The <time> element
  keeps the ISO string in `dateTime`. An unparseable `now` degrades to the
  absolute stamp rather than dropping the time.
- Day separators come from a civil-day key built out of Intl.DateTimeFormat
  parts forced to calendar:"gregory" and numberingSystem:"latn", so keys stay
  comparable under any locale. "Yesterday" is computed by lifting today's civil
  date onto a UTC-midnight ordinal and subtracting one day — subtracting 24h
  from the instant lands on the SAME civil day during a 25-hour DST fall-back
  evening, and "Yesterday" would silently never appear.
- Unread: everything strictly newer than `lastReadAt` is unread (undated entries
  can never be compared, so they count as read). Unread rows get a dot, a tinted
  background and an sr-only "Unread" prefix — never colour alone. Because the
  list is sorted by time, there is exactly ONE read/unread transition, and the
  divider is drawn there: it reads "Earlier" when read rows follow (newest-first)
  and "New" when unread rows follow (oldest-first).
- "Mark all as read" is painted only when `onMarkRead` exists AND something is
  unread. It is one-shot per boundary: the lock is a REF read and written inside
  the same synchronous handler, because two clicks in one tick both run before
  React re-renders and a state-only guard would fire the callback twice. It goes
  aria-disabled — never the `disabled` attribute, which makes the browser blur
  the button the reader is standing on. A new `lastReadAt` (or newer unread
  activity) re-arms it by itself.
- Folded rows grow a real <button type="button"> with aria-expanded /
  aria-controls listing every entry with its own actor, time and meta. The
  collapsed panel stays MOUNTED (so aria-controls resolves) but is display:none
  via BOTH the `hidden` attribute and the `hidden` class. Never collapse it with
  grid-rows-[0fr] or max-height:0 — a zero-height row keeps its links in the tab
  order, which is an invisible keyboard trap.
- Paging: `maxVisible` rows render and the button reveals another `maxVisible`
  per click, stating the exact remaining count; when the buffer is exhausted it
  calls `onLoadMore` instead. Focus moves to the first row the click revealed,
  because the button may unmount itself and drop focus on <body>. Drop
  `onLoadMore` once the cursor is exhausted and the button disappears rather
  than becoming a dead click.
- Keyboard, the ARIA feed pattern: the rows are <article> elements in a roving
  tabindex (ONE Tab stop for the whole feed). Page Down / Page Up move focus to
  the next / previous row and only call preventDefault when focus actually
  moved, so at the ends the keys still scroll. Ctrl+End / Ctrl+Home jump to the
  nearest tabbable element after / before the feed, skipping hidden nodes and
  any ancestor that contains the feed. Controls inside a row stay in the normal
  tab order.
- Degenerate data never crashes the tree: an unparseable `at` renders its raw
  string in a trailing "Undated" day; an invalid locale or IANA zone (both make
  Intl throw at construction) falls back to en-US / UTC; an actor whose name is
  blank becomes labels.unknownActor ("Someone") with a "?" face, because a
  sentence with no subject is not a sentence; an unbreakable URL target wraps
  instead of widening the row; a remote avatar that fails degrades to initials.
- No timers, no rAF, no observers, no listeners on window — nothing to clean up,
  and the whole component renders identically on the server.

Rendering & styling
- Semantic tokens only: bg-card as the assumed surface, bg-muted for faces and
  the meta line, text-foreground for actor names and target labels,
  text-muted-foreground for the verb, the timestamps and the separators,
  bg-primary / bg-primary/5 / bg-primary/10 for the unread dot, tint and count
  pill, text-destructive + border-destructive/40 for the error branch, ring-ring
  for every focus ring. No hex, no chart tokens on text.
- Layout: a 32px avatar column (stacked faces overlap by -12px with a ring-card
  gap and cap at three plus a "+N" bubble), then a min-w-0 flex column holding
  the sentence, the timestamp line, the meta excerpt and the disclosure. min-w-0
  and wrap-anywhere all the way down, or one long URL widens the whole card.
- The remote avatar sits ON TOP of the initials, so a slow or dead URL degrades
  to initials instead of a broken-image glyph; the failure is remembered per URL
  (not as a boolean) so a recycled row gives the next face its own chance, and
  the ref callback re-checks `complete && naturalWidth === 0` because a cached
  or already-failed image can finish before React attaches onError.
- The only motion is the loading pulse (motion-reduce:animate-none) and the
  chevron rotation plus hover colours (motion-reduce:transition-none). cn()
  merges the consumer's className into the root.
- Accessibility: the list is role="feed" with aria-busy while loading; every row
  is an <article> with aria-posinset, aria-setsize (-1 while `onLoadMore` can
  still bring more) and aria-labelledby pointing at its sentence and its time.
  Day and unread separators are aria-hidden decoration — the feed then owns
  articles only, as the role requires, and nothing is lost because each row
  already carries its own absolute date and its own "Unread" prefix.

Customization levers
- Folding: `collapse` picks the axis (many people on one thing / one person on
  many things / never), `groupWithinMs` picks how long a burst may last — drop
  it to 15 minutes for a chat-like stream, raise it to a day for a quiet
  project. `maxVisible` decides how much lands before the footer button.
- Sentence: `verbLabels` remaps machine keys to prose without touching the data;
  `renderSummary(group, ctx)` rewrites the whole sentence (word order, another
  language, an extra badge) while keeping the row chrome, and `ctx` hands back
  the resolved verb plus the already-built actor and target nodes; `labels`
  replaces every chrome string (Today / Yesterday / Undated / New / Earlier /
  the counts / the buttons) — with `locale`, that is the entire i18n surface.
- Density: drop `showMeta` for a one-line-per-row feed, set collapse="none" for
  a literal log, or raise MAX_FACES if your rows are wide enough for more faces.
- Time policy: switch `timeZone` per viewer, switch `locale` to change both the
  relative phrasing and the day labels, or feed a frozen `now` to make
  screenshots and tests deterministic.
- Unread: omit `lastReadAt` and the whole boundary disappears (no dot, no
  divider, no button) — that is the right shape for a public activity panel.
  Keep it and own the value upstream: the component reports the intent through
  `onMarkRead`, it never mutates your data.
- Tokens: bg-primary/5 on unread rows is the quietest useful tint; swap it for
  a left border if your rows already sit on a coloured surface, and keep the dot
  either way — colour alone is not a state.

Concepts

  • Run-length collapse — rows fold only with the row directly above them, and only inside one civil day, one side of the unread line and one time window. Global grouping would quietly reorder history so the sentences could be shorter; this way "four people commented within nine minutes" is one line and "the same person again eight hours later" is still its own.
  • The unread boundary is a cut, not a highlight — an unread entry and a read entry never share a row, so the divider can never slice through the middle of a sentence. And because the feed is sorted by time there is exactly one transition to draw: it says "Earlier" above the read rows in a newest-first feed and "New" above the unread ones when you flip the order.
  • Injected now — the reference instant is a required prop, not new Date(). That keeps render pure, makes "2 hours ago" and the Today separator reproducible in tests and screenshots, and stops the server and the browser from disagreeing about which day an entry belongs to.
  • Civil-day bucketing, not 24-hour arithmetic — days are keyed from Intl parts in an explicit zone, and "yesterday" is computed on a civil-date ordinal. Subtracting 24 hours from an instant lands on the same day during a 25-hour DST fall-back evening, and the Yesterday separator would quietly stop appearing once a year.
  • role="feed" earns its keyboard — the rows are articles in a roving tabindex, so the whole stream is one Tab stop and Page Down / Page Up walk it while Ctrl+Home / Ctrl+End step out of it. The day and unread separators are aria-hidden, which keeps the feed owning articles only — the date and the unread state live inside each row instead.
  • Mark-as-read is a report, never a mutation — the component fires onMarkRead at most once per boundary (a ref lock, read and written in the same tick, so a double click cannot double-fire) and then waits. The boundary moves when your lastReadAt moves, which is what keeps the feed honest across a refresh.

On This Page