Skip to contentVibraUI
Data display

Activity feed

A team's day as rows of who did what to which thing, optionally split by day.

A client component. "Today" depends on when the page is read, which the server cannot know, so the day headings are absolute dates through the server render and the hydrating one and turn relative on the commit after — the same useSyncExternalStore guard countdown uses. Pass now to skip the wait: an explicit reference is deterministic on both sides. Date.now() is never read during render for anything that reaches the DOM. groupByDay and dayHeading are exported so a caller can build the same grouping elsewhere; groupByDay buckets by calendar day and keeps the order it was given, so a feed sorted newest-first stays that way. Each row is a list item, meta gets a bordered box of its own, and icon becomes a small badge on the actor's avatar. A row with no actor is a row nobody did — a threshold crossed, a job finished — so it drops the avatar column, carries its icon at the avatar's size in its place, and writes its action in the reader's ink rather than the secondary tier; live-feed is built on that shape. A day heading is a div, not a heading element — the feed cannot know what level it sits at — and names its own list through aria-label instead.

Install

npx shadcn@latest add @vibra/activity-feed

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
items{ id: string; actor?: { name: string; src?: string; fallback?: string }; action: React.ReactNode; target?: React.ReactNode; time: Date | string; meta?: React.ReactNode; icon?: React.ReactNode }[]—The events, rendered in the order given.
groupByDaybooleanfalseSplits the feed into sticky day headings — Today, Yesterday, then the date.
emptyMessageReact.ReactNodeNo activity yet.Shown in place of the list when there is nothing to report.
nowDate | string | numberthe clock, after hydrationThe moment "Today" is measured against. Pass it to keep headings deterministic.
groupByDay()(items: ActivityFeedItem[]) => { key: string; date: Date; items: ActivityFeedItem[] }[]—Buckets items by calendar day, keeping the order they were given in.
dayHeading()(date: Date, now: Date | null) => string—Names a day — "Today", "Yesterday", then the date; a null now means the date.

Dependencies

Source

components/ui/activity-feed.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatDate, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { RelativeTime } from "@/components/ui/relative-time"

const DAY = 86_400_000

const toDate = (value: Date | string | number) => (value instanceof Date ? value : new Date(value))

// Midnight local, so two moments on the same calendar day compare equal even
// across a daylight-saving boundary.
const startOfDay = (date: Date) =>
  new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()

export type ActivityFeedItem = {
  id: string
  /**
   * Who did it. Left out for a row nothing did — a threshold crossed, a job
   * finished, a deploy landed — which drops the avatar column and lets the
   * action itself open the line.
   */
  actor?: { name: string; src?: string; fallback?: string }
  action: React.ReactNode
  target?: React.ReactNode
  time: Date | string
  /** A detail box under the row — a diff summary, a commit message, a note. */
  meta?: React.ReactNode
  /** A small badge on the actor's avatar saying what kind of event this is. */
  icon?: React.ReactNode
}

export type ActivityFeedDayGroup = { key: string; date: Date; items: ActivityFeedItem[] }

/** Buckets items by calendar day, keeping the order they were given in. */
export function groupByDay(items: ActivityFeedItem[]): ActivityFeedDayGroup[] {
  const groups: ActivityFeedDayGroup[] = []
  const byKey = new Map<string, ActivityFeedDayGroup>()

  for (const item of items) {
    const date = toDate(item.time)
    const key = String(startOfDay(date))
    const existing = byKey.get(key)
    if (existing) {
      existing.items.push(item)
      continue
    }
    const group: ActivityFeedDayGroup = { key, date, items: [item] }
    byKey.set(key, group)
    groups.push(group)
  }

  return groups
}

/**
 * Names a day relative to `now` — "Today", "Yesterday", then the date itself.
 * A null `now` means the clock is not readable yet, and the date is used.
 */
export function dayHeading(date: Date, now: Date | null): string {
  if (now === null) return formatDate(date, "medium")
  const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY)
  if (days === 0) return "Today"
  if (days === 1) return "Yesterday"
  return formatDate(date, "medium")
}

// Nothing to subscribe to: the store exists only to hand the server and the
// client different snapshots — the same pattern countdown and use-media-query
// use to reveal a browser-only value after hydration.
const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false

/** False through the server render and the hydrating one, true from the commit on. */
function useHasClock(): boolean {
  return React.useSyncExternalStore(subscribeToNothing, onClient, onServer)
}

export type ActivityFeedProps = React.ComponentProps<"div"> & {
  items: ActivityFeedItem[]
  /** Splits the feed into sticky day headings. */
  groupByDay?: boolean
  emptyMessage?: React.ReactNode
  /** The moment "Today" is measured against. Pass it to keep headings deterministic. */
  now?: Date | string | number
}

/** A team's day as a list of rows: who did what to which thing, and when. */
function ActivityFeed({
  className,
  items,
  groupByDay: grouped = false,
  emptyMessage = "No activity yet.",
  now,
  ...props
}: ActivityFeedProps) {
  // "Today" depends on when the page is read, which the server cannot know.
  // Until the clock is readable the headings are absolute dates, so the
  // hydrating markup matches the server's exactly; an explicit `now` is
  // deterministic on both sides and skips the wait.
  const hasClock = useHasClock()
  const [mountedNow] = React.useState(() => new Date())
  const reference = now !== undefined ? toDate(now) : hasClock ? mountedNow : null
  // The stamps take the same reference, so a row never reads "2d ago" under a
  // "Today" heading.
  const rowNow = now !== undefined ? toDate(now) : undefined

  const groups = grouped
    ? groupByDay(items)
    : [{ key: "all", date: mountedNow, items } satisfies ActivityFeedDayGroup]

  return (
    <div
      data-slot="activity-feed"
      data-grouped={grouped || undefined}
      className={cn("flex w-full flex-col", className)}
      {...props}
    >
      {items.length === 0 ? (
        <p data-slot="activity-feed-empty" className="py-8 text-center text-sm text-muted-foreground">
          {emptyMessage}
        </p>
      ) : (
        groups.map((group) => (
          <React.Fragment key={group.key}>
            {grouped ? (
              // A div, not a heading: the feed cannot know what level it sits
              // at. The day names the list under it instead.
              <div
                data-slot="activity-feed-day"
                className="sticky top-0 z-10 bg-background/85 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur-sm"
              >
                {dayHeading(group.date, reference)}
              </div>
            ) : null}
            <ol
              aria-label={grouped ? dayHeading(group.date, reference) : undefined}
              className="flex flex-col"
            >
              {group.items.map((item) => (
                <ActivityFeedRow now={rowNow} key={item.id} item={item} />
              ))}
            </ol>
          </React.Fragment>
        ))
      )}
    </div>
  )
}

function ActivityFeedRow({ item, now }: { item: ActivityFeedItem; now?: Date }) {
  const { actor } = item

  return (
    <li
      data-slot="activity-feed-item"
      data-system={actor ? undefined : true}
      className="flex gap-3 py-2.5"
    >
      {actor ? (
        <span className="relative shrink-0">
          <Avatar className="size-7">
            {actor.src ? <AvatarImage src={actor.src} alt="" /> : null}
            <AvatarFallback className="text-avatar">
              {actor.fallback ?? getInitials(actor.name)}
            </AvatarFallback>
          </Avatar>
          {item.icon ? (
            <span
              data-slot="activity-feed-item-icon"
              aria-hidden="true"
              className="absolute -end-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-background text-muted-foreground ring-2 ring-background [&_svg]:size-2.5"
            >
              {item.icon}
            </span>
          ) : null}
        </span>
      ) : item.icon ? (
        // No one to badge, so the glyph stands in the avatar's place at the
        // avatar's size rather than clinging to its corner.
        <span
          data-slot="activity-feed-item-icon"
          aria-hidden="true"
          className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-3.5"
        >
          {item.icon}
        </span>
      ) : null}

      <div className="flex min-w-0 flex-1 flex-col gap-1.5">
        <div className="flex flex-wrap items-baseline gap-x-1.5 text-sm">
          {actor ? (
            <span data-slot="activity-feed-item-actor" className="font-medium">
              {actor.name}
            </span>
          ) : null}
          {/* With no name in front of it the action is the line, so it is
              written in the reader's ink rather than the secondary tier. */}
          <span className={actor ? "text-muted-foreground" : "min-w-0"}>{item.action}</span>
          {item.target ? (
            <span data-slot="activity-feed-item-target" className="font-medium">
              {item.target}
            </span>
          ) : null}
          <RelativeTime
            now={now}
            date={item.time}
            className="ms-auto shrink-0 text-xs text-muted-foreground"
          />
        </div>
        {item.meta ? (
          <div
            data-slot="activity-feed-item-meta"
            className="rounded-md border bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground"
          >
            {item.meta}
          </div>
        ) : null}
      </div>
    </li>
  )
}

export { ActivityFeed }