A scrollable activity feed inspired by Linear and Slack. Shows user actions with avatars, relative timestamps, loading skeletons, and realistic team activity.
// 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
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Inline Data Table
Notion + Linear