Activity Feed

A scrollable activity feed inspired by Linear and Slack. Shows user actions with avatars, relative timestamps, loading skeletons, and realistic team activity.

Activity FeedsInspired by Linear + Slack
Live PreviewInteractive
ActivityFeed.tsx
// Dependencies: react ^18, lucide-react
import React from 'react';
import { Activity } from 'lucide-react';

type ActivityItem = {
  id: string;
  user: {
    name: string;
    avatar?: string;
    initials: string;
    color: string;
  };
  action: string;
  target?: string;
  targetType?: 'issue' | 'project' | 'comment' | 'file' | 'member';
  timestamp: Date;
  metadata?: string;
};

type Props = {
  items: ActivityItem[];
  loading?: boolean;
  maxHeight?: number;
};

function formatRelative(date: Date): string {
  const now = new Date();
  const diffMs = now.getTime() - date.getTime();
  const diffMins = Math.floor(diffMs / 60000);
  const diffHours = Math.floor(diffMins / 60);
  const diffDays = Math.floor(diffHours / 24);

  if (diffMins < 1) return 'Just now';
  if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
  if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
  if (diffDays === 1) {
    const h = date.getHours();
    const m = date.getMinutes();
    const ampm = h >= 12 ? 'pm' : 'am';
    return `Yesterday at ${h % 12 || 12}:${m.toString().padStart(2, '0')}${ampm}`;
  }
  return `${diffDays} days ago`;
}

export function ActivityFeed({ items, loading = false, maxHeight }: Props) {
  const style = maxHeight ? { maxHeight, overflowY: 'auto' as const } : {};

  if (loading) {
    return (
      <div className="flex flex-col" style={style}>
        {Array.from({ length: 5 }).map((_, i) => <SkeletonItem key={i} />)}
      </div>
    );
  }

  if (items.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center py-12 gap-3 text-center">
        <div className="w-10 h-10 rounded-xl bg-zinc-900 border border-[#2a2a2a] flex items-center justify-center">
          <Activity className="w-4 h-4 text-zinc-600" />
        </div>
        <p className="text-sm text-zinc-500">No activity yet</p>
      </div>
    );
  }

  return (
    <div className="flex flex-col" style={style}>
      {items.map((item, i) => (
        <FeedItem key={item.id} item={item} isLast={i === items.length - 1} />
      ))}
    </div>
  );
}

function FeedItem({ item, isLast }: { item: ActivityItem; isLast: boolean }) {
  return (
    <div
      className={`flex items-start gap-3 px-4 py-3 hover:bg-white/[0.02] transition-colors ${
        !isLast ? 'border-b border-white/[0.05]' : ''
      }`}
    >
      <div
        className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5 text-white text-xs font-bold"
        style={{ backgroundColor: item.user.color }}
      >
        {item.user.initials}
      </div>
      <div className="flex flex-col gap-0.5 flex-1 min-w-0">
        <p className="text-sm leading-snug">
          <span className="font-semibold text-white">{item.user.name}</span>
          <span className="text-zinc-500"> {item.action} </span>
          {item.target && (
            <span className="text-indigo-400 font-medium">{item.target}</span>
          )}
        </p>
        {item.metadata && (
          <p className="text-xs text-zinc-600 truncate">{item.metadata}</p>
        )}
        <p className="text-xs text-zinc-600">{formatRelative(item.timestamp)}</p>
      </div>
    </div>
  );
}

function SkeletonItem() {
  return (
    <div className="flex items-start gap-3 px-4 py-3 border-b border-white/[0.05] last:border-0">
      <div className="w-8 h-8 rounded-full bg-zinc-800 animate-pulse shrink-0 mt-0.5" />
      <div className="flex flex-col gap-2 flex-1 pt-1">
        <div className="h-2.5 rounded bg-zinc-800 animate-pulse w-3/4" />
        <div className="h-2 rounded bg-zinc-800/60 animate-pulse w-1/2" />
        <div className="h-2 rounded bg-zinc-800/40 animate-pulse w-1/4" />
      </div>
    </div>
  );
}

Unlock to copy

Free access to all patterns