shadimage
neon-ui
componentactivity-feedsource: ok · hit

ActivityFeed

A chronological feed of system events (provision, transfer, agent) on a status-vocabulary timeline, with expandable detail and optional per-entry actions.

npx shadcn add "https://ui.neon.com/r/activity-feed.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@hugeicons/react@hugeicons/core-free-icons

Registry dependencies

utilshttps://ui.neon.com/r/button.jsonhttps://ui.neon.com/r/skeleton.jsonhttps://ui.neon.com/r/empty-state.jsonhttps://ui.neon.com/r/neon-tokens.json

Files1 · source cached 10/9/2026, 11:57:41 PM

src/components/activity-feed/activity-feed.tsx
"use client";

import {
  ArrowDown01Icon,
  Copy01Icon,
  Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
import type { ComponentProps, ReactNode } from "react";

import { EmptyState } from "@/components/empty-state/empty-state";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";

/** The event's outcome, drawn from the house status vocabulary. */
export type ActivityStatus = "success" | "error" | "pending" | "info";

export interface ActivityEntry {
  id: string;
  status: ActivityStatus;
  /** One-line summary, e.g. "Provisioned production database". */
  title: string;
  /** Display-ready relative time, e.g. "2m ago". */
  timestamp: string;
  /** Who or what caused it, e.g. "agent" or a user name. */
  source?: string;
  /** Expandable long detail: an error message, request id, or result. */
  detail?: string;
  /** When set, renders an action (e.g. "Retry") that fires onAction. */
  actionLabel?: string;
}

/* Marker color per status; alive states breathe on the shared cadence. */
const MARKER: Record<ActivityStatus, string> = {
  error: "bg-destructive",
  info: "bg-muted-foreground/50",
  pending:
    "neon-status-breathe bg-current text-[var(--status-scaling)] motion-reduce:animate-none",
  success: "bg-primary",
};

const ROW_STAGGER_MS = 60;
const COPY_FLASH_MS = 1500;

/** Copies the detail text; the icon flashes a primary check. */
const CopyButton = ({ value }: { value: string }) => {
  const [copied, setCopied] = useState(false);

  const copy = async () => {
    setCopied(true);
    window.setTimeout(() => setCopied(false), COPY_FLASH_MS);

    try {
      await navigator.clipboard.writeText(value);
    } catch {
      // Clipboard unavailable; the check still flashes.
    }
  };

  return (
    <button
      aria-label={copied ? "Copied" : "Copy details"}
      className="absolute top-1 right-1 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground/50 outline-none transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
      onClick={copy}
      type="button"
    >
      <HugeiconsIcon
        className={cn("size-3 transition-colors", copied && "text-primary")}
        icon={copied ? Tick02Icon : Copy01Icon}
        strokeWidth={2}
      />
    </button>
  );
};
const RISE =
  "fill-mode-backwards fade-in-0 slide-in-from-bottom-2 animate-in duration-500 motion-reduce:animate-none";

const ActivityRow = ({
  entry,
  index,
  onAction,
}: {
  entry: ActivityEntry;
  index: number;
  onAction?: (entry: ActivityEntry) => void;
}) => {
  const [expanded, setExpanded] = useState(false);

  return (
    <li
      className={cn(
        "group relative flex items-start gap-3 pb-6 pl-6 last:pb-0",
        RISE
      )}
      data-slot="activity-row"
      data-status={entry.status}
      style={{ animationDelay: `${index * ROW_STAGGER_MS}ms` }}
    >
      <span
        aria-hidden="true"
        className={cn(
          "absolute top-[7px] left-[-2.5px] size-1.5 shrink-0",
          MARKER[entry.status]
        )}
        data-slot="activity-marker"
      />

      <div className="flex min-w-0 flex-1 flex-col gap-1">
        <div className="flex items-baseline gap-2.5">
          <p
            className="truncate font-medium text-foreground text-sm"
            title={entry.title}
          >
            {entry.title}
          </p>
          <span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/70 tabular-nums">
            {entry.timestamp}
          </span>
        </div>

        {entry.source ? (
          <p className="font-mono text-[10px] text-muted-foreground/70">
            {entry.source}
          </p>
        ) : null}

        {entry.detail ? (
          <div className="mt-0.5">
            <button
              aria-expanded={expanded}
              className="inline-flex items-center gap-1 font-mono text-[10px] text-muted-foreground/70 outline-none transition-colors hover:text-foreground focus-visible:text-foreground"
              onClick={() => setExpanded((current) => !current)}
              type="button"
            >
              <HugeiconsIcon
                className={cn(
                  "size-3 transition-transform duration-200",
                  expanded && "rotate-180"
                )}
                icon={ArrowDown01Icon}
                strokeWidth={2}
              />
              {expanded ? "Hide details" : "Details"}
            </button>
            {expanded ? (
              <div className="fade-in-0 slide-in-from-top-1 relative mt-1.5 animate-in rounded-md border border-border/60 bg-background duration-200 motion-reduce:animate-none">
                <CopyButton value={entry.detail} />
                <div className="max-h-44 overflow-auto whitespace-pre-wrap p-2.5 pr-9 font-mono text-[10px] text-muted-foreground leading-relaxed">
                  {entry.detail}
                </div>
              </div>
            ) : null}
          </div>
        ) : null}

        {entry.actionLabel && onAction ? (
          <div className="mt-0.5">
            <Button
              className="h-6 rounded-full border border-border/60 px-2.5 text-muted-foreground text-xs transition-colors hover:border-border hover:bg-transparent hover:text-foreground active:scale-[0.98]"
              onClick={() => onAction(entry)}
              size="sm"
              variant="ghost"
            >
              {entry.actionLabel}
            </Button>
          </div>
        ) : null}
      </div>
    </li>
  );
};

export type ActivityFeedProps = Omit<ComponentProps<"div">, "children"> & {
  /** Events, newest first. The top row reads as "now". */
  entries: ActivityEntry[];
  /** Card title. */
  label?: string;
  /** Fires when an entry's action is used. */
  onAction?: (entry: ActivityEntry) => void;
  /** Override the built-in empty state. */
  empty?: ReactNode;
  isLoading?: boolean;
  error?: Error | string | null;
};

const cardClassName =
  "flex w-full min-w-0 flex-col rounded-lg border border-border/60 bg-card p-4 shadow-none ring-0";

const Header = ({ label, count }: { label: string; count?: number }) => (
  <div className="flex items-center justify-between gap-3">
    <p className="font-medium text-foreground text-sm">{label}</p>
    {count === undefined ? null : (
      <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums">
        {count}
      </span>
    )}
  </div>
);

export const ActivityFeed = ({
  entries,
  label = "Activity",
  onAction,
  empty,
  isLoading = false,
  error = null,
  className,
  ...props
}: ActivityFeedProps) => {
  if (isLoading) {
    return (
      <div
        aria-busy="true"
        className={cn(cardClassName, className)}
        data-slot="activity-feed"
        {...props}
      >
        <Header label={label} />
        <div className="mt-4 space-y-4">
          <Skeleton aria-hidden="true" className="h-8 w-full" />
          <Skeleton aria-hidden="true" className="h-8 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    const message = typeof error === "string" ? error : error.message;

    return (
      <div
        className={cn(cardClassName, className)}
        data-slot="activity-feed"
        role="alert"
        {...props}
      >
        <Header label={label} />
        <p className="mt-3 flex items-baseline gap-2 text-sm">
          <span className="font-mono text-destructive text-xs">error</span>
          <span className="text-foreground">{message}</span>
        </p>
      </div>
    );
  }

  return (
    <div
      className={cn(cardClassName, className)}
      data-slot="activity-feed"
      {...props}
    >
      <Header count={entries.length} label={label} />

      {entries.length === 0 ? (
        <div className="mt-3">
          {empty ?? (
            <EmptyState
              description="System events land here as your app provisions, transfers, and runs."
              title="No activity yet"
            />
          )}
        </div>
      ) : (
        <ol className="relative mt-4 flex flex-col">
          <span
            aria-hidden="true"
            className="absolute top-2 bottom-3 left-0 w-px bg-border"
          />
          {entries.map((entry, index) => (
            <ActivityRow
              entry={entry}
              index={index}
              key={entry.id}
              onAction={onAction}
            />
          ))}
        </ol>
      )}
    </div>
  );
};

More from neon-ui

Browse all components in this library →