Skip to content
6

Activity Feed

Avatar-led event feed with a connecting rail, actor and action lines, timestamps, quoted bodies and date dividers. Ships composable parts.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/activity-feed.json

Code

components/blocks/activity-feed.tsx
'use client';

import * as React from 'react';
import { GitMerge, UserPlus } from 'lucide-react';

import { cn } from '@/lib/utils';

type ActivityFeedProps = React.ComponentProps<'ul'>;

const ActivityFeed = ({ className, ...props }: ActivityFeedProps) => {
  return <ul data-slot="activity-feed" className={cn('flex flex-col', className)} {...props} />;
};

type ActivityFeedItemProps = React.ComponentProps<'li'>;

const ActivityFeedItem = ({ className, ...props }: ActivityFeedItemProps) => {
  return (
    <li
      data-slot="activity-feed-item"
      className={cn(
        'group relative flex gap-3 pb-5 last:pb-0',
        'before:absolute before:start-[15px] before:top-9 before:bottom-0 before:w-px before:bg-border',
        'last:before:hidden',
        className,
      )}
      {...props}
    />
  );
};

interface ActivityFeedAvatarProps extends React.ComponentProps<'span'> {
  src?: string;
  alt?: string;
}

const ActivityFeedAvatar = ({ src, alt, className, children, ...props }: ActivityFeedAvatarProps) => {
  return (
    <span
      data-slot="activity-feed-avatar"
      className={cn(
        'relative z-10 inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-muted text-[11px] font-medium text-muted-foreground [&_svg]:size-4',
        className,
      )}
      {...props}
    >
      {src ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={src} alt={alt ?? ''} loading="lazy" className="size-full object-cover" />
      ) : (
        children
      )}
    </span>
  );
};

type ActivityFeedContentProps = React.ComponentProps<'div'>;

const ActivityFeedContent = ({ className, ...props }: ActivityFeedContentProps) => {
  return (
    <div
      data-slot="activity-feed-content"
      className={cn('flex min-w-0 flex-1 flex-col gap-1 pt-1', className)}
      {...props}
    />
  );
};

type ActivityFeedHeaderProps = React.ComponentProps<'div'>;

const ActivityFeedHeader = ({ className, ...props }: ActivityFeedHeaderProps) => {
  return (
    <div
      data-slot="activity-feed-header"
      className={cn('flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm leading-snug', className)}
      {...props}
    />
  );
};

type ActivityFeedActorProps = React.ComponentProps<'span'>;

const ActivityFeedActor = ({ className, ...props }: ActivityFeedActorProps) => {
  return <span data-slot="activity-feed-actor" className={cn('font-medium text-foreground', className)} {...props} />;
};

type ActivityFeedActionProps = React.ComponentProps<'span'>;

const ActivityFeedAction = ({ className, ...props }: ActivityFeedActionProps) => {
  return <span data-slot="activity-feed-action" className={cn('text-muted-foreground', className)} {...props} />;
};

type ActivityFeedTimeProps = React.ComponentProps<'time'>;

const ActivityFeedTime = ({ className, ...props }: ActivityFeedTimeProps) => {
  return (
    <time
      data-slot="activity-feed-time"
      className={cn('font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground', className)}
      {...props}
    />
  );
};

type ActivityFeedBodyProps = React.ComponentProps<'div'>;

const ActivityFeedBody = ({ className, ...props }: ActivityFeedBodyProps) => {
  return (
    <div
      data-slot="activity-feed-body"
      className={cn('mt-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground', className)}
      {...props}
    />
  );
};

type ActivityFeedDividerProps = React.ComponentProps<'li'>;

const ActivityFeedDivider = ({ className, children, ...props }: ActivityFeedDividerProps) => {
  return (
    <li data-slot="activity-feed-divider" className={cn('flex items-center gap-3 pb-5', className)} {...props}>
      {children ? (
        <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{children}</span>
      ) : null}
      <span aria-hidden className="h-px flex-1 bg-border" />
    </li>
  );
};

export {
  ActivityFeed,
  ActivityFeedItem,
  ActivityFeedAvatar,
  ActivityFeedContent,
  ActivityFeedHeader,
  ActivityFeedActor,
  ActivityFeedAction,
  ActivityFeedTime,
  ActivityFeedBody,
  ActivityFeedDivider,
};

const ActivityFeedBlock = () => {
  return (
    <section data-slot="activity-feed-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
      <div className="grid w-full max-w-xl gap-3">
        <p className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">Team activity</p>
        <ActivityFeed>
          <ActivityFeedDivider>Today</ActivityFeedDivider>
          <ActivityFeedItem>
            <ActivityFeedAvatar>
              <GitMerge className="text-foreground" />
            </ActivityFeedAvatar>
            <ActivityFeedContent>
              <ActivityFeedHeader>
                <ActivityFeedActor>Lena Park</ActivityFeedActor>
                <ActivityFeedAction>
                  merged <span className="font-mono">feat/billing</span> into main
                </ActivityFeedAction>
                <ActivityFeedTime className="ms-auto">14:20</ActivityFeedTime>
              </ActivityFeedHeader>
            </ActivityFeedContent>
          </ActivityFeedItem>
          <ActivityFeedItem>
            <ActivityFeedAvatar>MS</ActivityFeedAvatar>
            <ActivityFeedContent>
              <ActivityFeedHeader>
                <ActivityFeedActor>Mara Singh</ActivityFeedActor>
                <ActivityFeedAction>commented on PR #1284</ActivityFeedAction>
                <ActivityFeedTime className="ms-auto">13:58</ActivityFeedTime>
              </ActivityFeedHeader>
              <ActivityFeedBody>
                Looks good. Can we add a test for the proration edge case before this ships?
              </ActivityFeedBody>
            </ActivityFeedContent>
          </ActivityFeedItem>
          <ActivityFeedDivider>Yesterday</ActivityFeedDivider>
          <ActivityFeedItem>
            <ActivityFeedAvatar>
              <UserPlus className="text-foreground" />
            </ActivityFeedAvatar>
            <ActivityFeedContent>
              <ActivityFeedHeader>
                <ActivityFeedActor>Theo Adams</ActivityFeedActor>
                <ActivityFeedAction>joined the workspace</ActivityFeedAction>
                <ActivityFeedTime className="ms-auto">17:02</ActivityFeedTime>
              </ActivityFeedHeader>
            </ActivityFeedContent>
          </ActivityFeedItem>
        </ActivityFeed>
      </div>
    </section>
  );
};

export default ActivityFeedBlock;

Dependencies

npm

lucide-react