Sections

Activity Feed

A day-grouped team activity stream where simulated live events blur-rise in at the top without shifting the rest of the list.

Install

npx shadcn@latest add @paragon/activity-feed

activity-feed.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

interface ActivityItem {
  id: string;
  initials: string;
  hue: string;
  actor: string;
  verb: string;
  object: string;
  context?: string;
  time: string;
}

const HUES = {
  violet:
    "bg-violet-600/10 text-violet-700 dark:bg-violet-400/10 dark:text-violet-300",
  sky: "bg-sky-600/10 text-sky-700 dark:bg-sky-400/10 dark:text-sky-300",
  emerald:
    "bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300",
  amber:
    "bg-amber-600/10 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300",
  rose: "bg-rose-600/10 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300",
};

const TODAY: ActivityItem[] = [
  {
    id: "t1",
    initials: "MC",
    hue: HUES.violet,
    actor: "Maya Chen",
    verb: "deployed",
    object: "api-gateway",
    context: "to production",
    time: "12m",
  },
  {
    id: "t2",
    initials: "JO",
    hue: HUES.sky,
    actor: "Jonas Okafor",
    verb: "approved",
    object: "PR #2481",
    context: "in checkout-service",
    time: "44m",
  },
  {
    id: "t3",
    initials: "SK",
    hue: HUES.emerald,
    actor: "Sara Kim",
    verb: "rotated secret",
    object: "STRIPE_API_KEY",
    context: "in staging",
    time: "1h",
  },
];

const YESTERDAY: ActivityItem[] = [
  {
    id: "y1",
    initials: "DP",
    hue: HUES.amber,
    actor: "Diego Paz",
    verb: "rolled back",
    object: "web-frontend",
    context: "to v2.14.3",
    time: "18h",
  },
  {
    id: "y2",
    initials: "MC",
    hue: HUES.violet,
    actor: "Maya Chen",
    verb: "created environment",
    object: "load-test",
    time: "21h",
  },
  {
    id: "y3",
    initials: "AL",
    hue: HUES.rose,
    actor: "Ana Lima",
    verb: "invited",
    object: "tom@meridianlabs.io",
    context: "as Member",
    time: "1d",
  },
  {
    id: "y4",
    initials: "JO",
    hue: HUES.sky,
    actor: "Jonas Okafor",
    verb: "archived project",
    object: "legacy-worker",
    time: "1d",
  },
];

const INCOMING: ActivityItem[] = [
  {
    id: "n1",
    initials: "SK",
    hue: HUES.emerald,
    actor: "Sara Kim",
    verb: "deployed",
    object: "billing-service",
    context: "to production",
    time: "just now",
  },
  {
    id: "n2",
    initials: "AL",
    hue: HUES.rose,
    actor: "Ana Lima",
    verb: "commented on",
    object: "PR #2486",
    time: "just now",
  },
  {
    id: "n3",
    initials: "DP",
    hue: HUES.amber,
    actor: "Diego Paz",
    verb: "scaled",
    object: "api-gateway",
    context: "to 12 replicas",
    time: "just now",
  },
  {
    id: "n4",
    initials: "MC",
    hue: HUES.violet,
    actor: "Maya Chen",
    verb: "merged",
    object: "PR #2483",
    context: "in api-gateway",
    time: "just now",
  },
  {
    id: "n5",
    initials: "JO",
    hue: HUES.sky,
    actor: "Jonas Okafor",
    verb: "enabled",
    object: "preview environments",
    context: "in web-frontend",
    time: "just now",
  },
];

/* ------------------------------------------------------------------ */

function Row({ item }: { item: ActivityItem }) {
  return (
    <div className="flex items-center gap-3 py-2.5">
      <span
        aria-hidden
        className={cn(
          "flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold",
          item.hue,
        )}
      >
        {item.initials}
      </span>
      <p className="min-w-0 flex-1 truncate text-sm">
        <span className="font-medium">{item.actor}</span>{" "}
        <span className="text-muted-foreground">{item.verb}</span>{" "}
        <span className="inline-flex max-w-full items-center rounded-md bg-muted px-1.5 py-px align-[-1.5px] font-mono text-xs">
          {item.object}
        </span>
        {item.context && (
          <span className="text-muted-foreground"> {item.context}</span>
        )}
      </p>
      <time className="shrink-0 text-xs text-muted-foreground tabular-nums">
        {item.time}
      </time>
    </div>
  );
}

/** Expands via grid-template-rows (the sanctioned height exception) with a blur-rise on the content. */
function ExpandIn({
  children,
  animate,
}: {
  children: React.ReactNode;
  animate: boolean;
}) {
  const [open, setOpen] = React.useState(!animate);

  React.useEffect(() => {
    if (!animate) return;
    // Two frames so the collapsed state paints before the transition retargets.
    let raf2 = 0;
    const raf1 = requestAnimationFrame(() => {
      raf2 = requestAnimationFrame(() => setOpen(true));
    });
    return () => {
      cancelAnimationFrame(raf1);
      cancelAnimationFrame(raf2);
    };
  }, [animate]);

  return (
    <div
      className={cn(
        "grid transition-[grid-template-rows] duration-300 [transition-timing-function:var(--ease-out)]",
        open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
      )}
    >
      <div className="min-h-0 overflow-hidden">
        <div
          className={cn(
            "transition-[opacity,transform,filter] duration-300 [transition-timing-function:var(--ease-out)]",
            open
              ? "translate-y-0 opacity-100 blur-none"
              : "translate-y-2 opacity-0 blur-[4px]",
          )}
        >
          {children}
        </div>
      </div>
    </div>
  );
}

export interface ActivityFeedProps extends React.ComponentProps<"section"> {
  /** Milliseconds between simulated incoming events. */
  interval?: number;
  /** Disables the live simulation. */
  static?: boolean;
}

/**
 * A team activity stream grouped by day. A simulation drops a new event in at
 * the top every few seconds: the row expands via grid-template-rows and
 * blur-rises into place, so existing rows glide instead of jumping. The
 * simulation pauses while the feed is offscreen or the tab is hidden, and
 * stops once its event pool is drained.
 */
export function ActivityFeed({
  interval = 3800,
  static: isStatic = false,
  className,
  ...props
}: ActivityFeedProps) {
  const reducedMotion = useReducedMotion();
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { amount: 0.2 });
  const [today, setToday] = React.useState<ActivityItem[]>(TODAY);
  const [injected, setInjected] = React.useState(0);
  const nextRef = React.useRef(0);

  React.useEffect(() => {
    if (isStatic || !inView || injected >= INCOMING.length) return;
    const id = setInterval(() => {
      if (document.visibilityState !== "visible") return;
      const n = nextRef.current;
      if (n >= INCOMING.length) return;
      nextRef.current = n + 1;
      const item = INCOMING[n];
      setToday((items) => [item, ...items]);
      setInjected(n + 1);
    }, interval);
    return () => clearInterval(id);
  }, [isStatic, inView, injected, interval]);

  return (
    <section
      ref={ref}
      aria-label="Team activity"
      className={cn(
        "w-full max-w-xl rounded-xl bg-card p-5 text-card-foreground shadow-border sm:p-6",
        className,
      )}
      {...props}
    >
      <header className="flex items-center justify-between gap-4">
        <h2 className="text-sm font-semibold">Activity</h2>
        <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
          <span
            aria-hidden
            className="size-1.5 rounded-full bg-emerald-500"
          />
          Live
        </span>
      </header>

      <div className="mt-3">
        <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
          Today
        </h3>
        <div className="mt-1 divide-y divide-border/60" aria-live="polite">
          {today.map((item, i) => (
            <ExpandIn
              key={item.id}
              animate={!reducedMotion && i === 0 && injected > 0}
            >
              <Row item={item} />
            </ExpandIn>
          ))}
        </div>
      </div>

      <div className="mt-5">
        <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
          Yesterday
        </h3>
        <div className="mt-1 divide-y divide-border/60">
          {YESTERDAY.map((item) => (
            <Row key={item.id} item={item} />
          ))}
        </div>
      </div>
    </section>
  );
}