Particular.Tech
Data displayui

Activity feed

Timeline of who did what, and when.

  1. AL

    Ada Lovelace deployed acme-web to production

  2. GH

    Grace Hopper added the env var STRIPE_SECRET_KEY

  3. Particular.Tech rotated the database password

    Scheduled rotation, no action needed.
  4. LT

    Linus Torvalds invited mh@acme.dev as a Developer

Installation

npx shadcn@latest add @particular/activity-feed
npm dependencies
  • cn@^0.4.0
Installs these registry items

Source

ui/activity-feed.tsx
import * as React from "react"
import { cn } from "cn"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { RelativeTime } from "@/components/ui/relative-time"

type ActivityItem = {
  id: string
  actor: { name: string; avatarUrl?: string }
  /** Sentence fragment after the actor's name, e.g. "deployed api to production". */
  action: React.ReactNode
  timestamp: Date | string | number
  /** Replaces the avatar, e.g. an icon for system events. */
  icon?: React.ReactNode
  detail?: React.ReactNode
}

function initials(name: string) {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? "")
    .join("")
}

function ActivityFeed({
  items,
  className,
  ...props
}: Omit<React.ComponentProps<"ol">, "children"> & { items: ActivityItem[] }) {
  return (
    <ol data-slot="activity-feed" className={cn("flex flex-col", className)} {...props}>
      {items.map((item, index) => (
        <li key={item.id} className="relative flex gap-3 pb-6 last:pb-0">
          {index < items.length - 1 && (
            <span aria-hidden className="absolute top-8 bottom-1 left-3.5 w-px -translate-x-1/2 bg-border" />
          )}
          {item.icon ? (
            <span className="flex size-7 shrink-0 items-center justify-center rounded-full border bg-muted text-muted-foreground [&_svg]:size-3.5">
              {item.icon}
            </span>
          ) : (
            <Avatar className="size-7">
              {item.actor.avatarUrl && <AvatarImage src={item.actor.avatarUrl} alt="" />}
              <AvatarFallback className="text-[0.625rem]">{initials(item.actor.name)}</AvatarFallback>
            </Avatar>
          )}
          <div className="flex min-w-0 flex-1 flex-col gap-1 pt-1">
            <p className="text-sm leading-tight">
              <span className="font-medium">{item.actor.name}</span>{" "}
              <span className="text-muted-foreground">{item.action}</span>
            </p>
            {item.detail && <div className="text-sm text-muted-foreground">{item.detail}</div>}
            <RelativeTime date={item.timestamp} className="w-fit text-xs text-muted-foreground" />
          </div>
        </li>
      ))}
    </ol>
  )
}

export { ActivityFeed, type ActivityItem }