Web InnoventixFreeCode

Dashboard Activity

Original · free

recent activity feed widget

byWeb InnoventixReact + Tailwind
dashactivitydashboards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/dash-activity.json
dash-activity.tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Kind = "deploy" | "comment" | "alert" | "member";
type Filter = "all" | Kind;

interface Activity {
  id: string;
  kind: Kind;
  actor: string;
  initials: string;
  action: string;
  target: string;
  meta: string;
  detail: string;
  minutesAgo: number;
}

type StreamEvent = Omit<Activity, "id" | "minutesAgo">;

const KIND: Record<Kind, { label: string; badge: string; rail: string }> = {
  deploy: {
    label: "Deploy",
    badge:
      "bg-emerald-50 text-emerald-600 ring-emerald-200/80 dark:bg-emerald-500/10 dark:text-emerald-400 dark:ring-emerald-500/25",
    rail: "bg-emerald-500",
  },
  comment: {
    label: "Comment",
    badge:
      "bg-sky-50 text-sky-600 ring-sky-200/80 dark:bg-sky-500/10 dark:text-sky-400 dark:ring-sky-500/25",
    rail: "bg-sky-500",
  },
  alert: {
    label: "Alert",
    badge:
      "bg-rose-50 text-rose-600 ring-rose-200/80 dark:bg-rose-500/10 dark:text-rose-400 dark:ring-rose-500/25",
    rail: "bg-rose-500",
  },
  member: {
    label: "Team",
    badge:
      "bg-violet-50 text-violet-600 ring-violet-200/80 dark:bg-violet-500/10 dark:text-violet-400 dark:ring-violet-500/25",
    rail: "bg-violet-500",
  },
};

const FILTERS: ReadonlyArray<{ id: Filter; label: string }> = [
  { id: "all", label: "All" },
  { id: "deploy", label: "Deploys" },
  { id: "comment", label: "Comments" },
  { id: "alert", label: "Alerts" },
  { id: "member", label: "Team" },
];

const SEED: ReadonlyArray<Activity> = [
  {
    id: "dact-seed-1",
    kind: "deploy",
    actor: "Priya Raman",
    initials: "PR",
    action: "shipped",
    target: "#2841 · retry failed card charges",
    meta: "+312 −88 · 14 files · build 4m 12s",
    detail:
      "Live in production behind the checkout.retry_v2 flag at 4% of traffic. Auth-decline retries now wait 90 seconds instead of firing immediately, which took duplicate-charge reports in staging to zero across a full week of replayed traffic.",
    minutesAgo: 4,
  },
  {
    id: "dact-seed-2",
    kind: "alert",
    actor: "Uptime check",
    initials: "UC",
    action: "flagged elevated latency on",
    target: "api.checkout · p95",
    meta: "p95 1.84s · threshold 1.20s · 6 min window",
    detail:
      "Latency climbed right after the 09:12 deploy. Three of eleven pods are still draining connections from the previous release. The autoscaler added two replicas and p95 is already trending back toward 1.1s.",
    minutesAgo: 11,
  },
  {
    id: "dact-seed-3",
    kind: "comment",
    actor: "Daniel Okafor",
    initials: "DO",
    action: "left a review comment on",
    target: "#2839 · webhook signature check",
    meta: "1 blocking · 2 suggestions",
    detail:
      "“We're comparing signatures with a plain equality check, which leaks timing. Swap it for a constant-time compare before this goes out — everything else in the diff reads clean.”",
    minutesAgo: 26,
  },
  {
    id: "dact-seed-4",
    kind: "member",
    actor: "Aisha Verma",
    initials: "AV",
    action: "joined the workspace as",
    target: "Billing admin",
    meta: "SSO verified · invited 2 days ago",
    detail:
      "Access granted to Billing, Invoices, and Payout settings. No deploy rights and no production database access — those still sit with the platform group.",
    minutesAgo: 47,
  },
  {
    id: "dact-seed-5",
    kind: "deploy",
    actor: "Marco Ellis",
    initials: "ME",
    action: "reverted",
    target: "v2.19.3 · pricing table",
    meta: "rollback finished in 41s",
    detail:
      "The experiment split was routing 100% of EU traffic to variant B. Rolled back to v2.19.2 and re-queued the test with the geo filter corrected. No customer-visible pricing changed during the 6-minute window.",
    minutesAgo: 88,
  },
  {
    id: "dact-seed-6",
    kind: "comment",
    actor: "Lena Fischer",
    initials: "LF",
    action: "resolved a thread on",
    target: "#2834 · invoice PDF spacing",
    meta: "4 replies · closed",
    detail:
      "Shipped the 8px baseline fix. Line items no longer collide with the tax summary on invoices longer than nine rows.",
    minutesAgo: 154,
  },
];

const STREAM: ReadonlyArray<StreamEvent> = [
  {
    kind: "comment",
    actor: "Priya Raman",
    initials: "PR",
    action: "requested changes on",
    target: "#2846 · idempotency keys",
    meta: "2 blocking",
    detail:
      "The key TTL is 24 hours but our retry window runs to 72. A legitimate retry on day three would sail past the cache and create a second charge.",
  },
  {
    kind: "deploy",
    actor: "Marco Ellis",
    initials: "ME",
    action: "promoted",
    target: "v2.20.0 · checkout",
    meta: "canary 4% → 25%",
    detail:
      "Error rate held at 0.02% through thirty minutes at 4%. Widening to a quarter of traffic; the full rollout is gated on the next clean thirty-minute window.",
  },
  {
    kind: "member",
    actor: "Tomás Ibarra",
    initials: "TI",
    action: "was granted",
    target: "Deploy access · staging",
    meta: "approved by Daniel Okafor",
    detail:
      "Scoped to staging only. Production deploys still require two approvals from the platform group.",
  },
  {
    kind: "alert",
    actor: "Uptime check",
    initials: "UC",
    action: "recovered",
    target: "api.checkout · p95",
    meta: "p95 1.06s · 12 min green",
    detail:
      "Old pods finished draining and the two extra replicas absorbed the spillover. Latency is back inside the 1.20s threshold with room to spare.",
  },
];

function formatAge(minutes: number): string {
  if (minutes <= 0) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.floor(hours / 24)}d ago`;
}

function KindIcon({ kind }: { kind: Kind }) {
  const common = {
    width: 18,
    height: 18,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.8,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
  };
  if (kind === "deploy") {
    return (
      <svg {...common}>
        <circle cx="6" cy="6" r="2.5" />
        <circle cx="6" cy="18" r="2.5" />
        <circle cx="18" cy="12" r="2.5" />
        <path d="M6 8.5v7" />
        <path d="M15.5 12H12a6 6 0 0 1-6-6" />
      </svg>
    );
  }
  if (kind === "comment") {
    return (
      <svg {...common}>
        <path d="M20 15a2 2 0 0 1-2 2H8l-4 3V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2Z" />
        <path d="M8.5 9.5h7" />
        <path d="M8.5 12.5h4" />
      </svg>
    );
  }
  if (kind === "alert") {
    return (
      <svg {...common}>
        <path d="M10.3 4.3 2.8 17a2 2 0 0 0 1.7 3h15a2 2 0 0 0 1.7-3l-7.5-12.7a2 2 0 0 0-3.4 0Z" />
        <path d="M12 9.5v4" />
        <path d="M12 17h.01" />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <path d="M15 20v-1.5a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4V20" />
      <circle cx="8.5" cy="7.5" r="3.5" />
      <path d="M19 8v6" />
      <path d="M22 11h-6" />
    </svg>
  );
}

export default function DashActivity() {
  const reduced = useReducedMotion();
  const uid = useId();
  const feedId = `${uid}-feed`;

  const [items, setItems] = useState<ReadonlyArray<Activity>>(SEED);
  const [filter, setFilter] = useState<Filter>("all");
  const [live, setLive] = useState(true);
  const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
  const [unread, setUnread] = useState<ReadonlySet<string>>(
    () => new Set(["dact-seed-1", "dact-seed-2", "dact-seed-3"]),
  );

  const streamIndex = useRef(0);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);

  useEffect(() => {
    if (!live) return;
    const timer = window.setInterval(() => {
      const next = STREAM[streamIndex.current % STREAM.length];
      streamIndex.current += 1;
      const id = `dact-live-${streamIndex.current}`;
      setItems((prev) =>
        [
          { ...next, id, minutesAgo: 0 },
          ...prev.map((item) => ({ ...item, minutesAgo: item.minutesAgo + 1 })),
        ].slice(0, 14),
      );
      setUnread((prev) => {
        const nextSet = new Set(prev);
        nextSet.add(id);
        return nextSet;
      });
    }, 5200);
    return () => window.clearInterval(timer);
  }, [live]);

  const counts = useMemo(() => {
    const base: Record<Filter, number> = {
      all: items.length,
      deploy: 0,
      comment: 0,
      alert: 0,
      member: 0,
    };
    for (const item of items) base[item.kind] += 1;
    return base;
  }, [items]);

  const visible = useMemo(
    () => (filter === "all" ? items : items.filter((item) => item.kind === filter)),
    [items, filter],
  );

  const toggle = useCallback((id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
    setUnread((prev) => {
      if (!prev.has(id)) return prev;
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  }, []);

  const onTabKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    let target = -1;
    if (event.key === "ArrowRight") target = (index + 1) % FILTERS.length;
    else if (event.key === "ArrowLeft") target = (index - 1 + FILTERS.length) % FILTERS.length;
    else if (event.key === "Home") target = 0;
    else if (event.key === "End") target = FILTERS.length - 1;
    if (target === -1) return;
    event.preventDefault();
    setFilter(FILTERS[target].id);
    tabRefs.current[target]?.focus();
  }, []);

  const ring =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes dact-ping {
          75%, 100% { transform: scale(2.1); opacity: 0; }
        }
        @keyframes dact-bar {
          0% { transform: scaleX(0); }
          100% { transform: scaleX(1); }
        }
        .dact-ping { animation: dact-ping 1.9s cubic-bezier(0, 0, 0.2, 1) infinite; }
        .dact-bar { animation: dact-bar 5.2s linear infinite; transform-origin: left; }
        @media (prefers-reduced-motion: reduce) {
          .dact-ping, .dact-bar { animation: none !important; }
          .dact-bar { transform: scaleX(1); }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <div className="overflow-hidden rounded-3xl border border-slate-200/80 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
          <header className="flex flex-wrap items-start justify-between gap-4 border-b border-slate-200/80 px-5 pb-4 pt-5 sm:px-6 dark:border-slate-800">
            <div className="min-w-0">
              <div className="flex items-center gap-2">
                <h2 className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-50">
                  Recent activity
                </h2>
                {unread.size > 0 && (
                  <span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-600 px-1.5 text-[11px] font-semibold tabular-nums text-white dark:bg-indigo-500">
                    {unread.size}
                  </span>
                )}
              </div>
              <p className="mt-0.5 truncate text-sm text-slate-500 dark:text-slate-400">
                Checkout revamp · 9 collaborators
              </p>
            </div>

            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => setUnread(new Set())}
                disabled={unread.size === 0}
                className={`rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:pointer-events-none disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800 ${ring}`}
              >
                Mark all read
              </button>

              <button
                type="button"
                role="switch"
                aria-checked={live}
                aria-label="Stream new activity as it happens"
                onClick={() => setLive((value) => !value)}
                className={`group inline-flex items-center gap-2 rounded-lg border border-slate-200 px-2.5 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 ${ring}`}
              >
                <span className="relative flex h-2 w-2">
                  {live && (
                    <span className="dact-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-60" />
                  )}
                  <span
                    className={`relative inline-flex h-2 w-2 rounded-full ${
                      live ? "bg-emerald-500" : "bg-slate-400 dark:bg-slate-600"
                    }`}
                  />
                </span>
                {live ? "Live" : "Paused"}
              </button>
            </div>
          </header>

          <div
            role="tablist"
            aria-label="Filter activity by type"
            className="flex flex-wrap gap-1.5 border-b border-slate-200/80 px-5 py-3 sm:px-6 dark:border-slate-800"
          >
            {FILTERS.map((tab, index) => {
              const selected = filter === tab.id;
              return (
                <button
                  key={tab.id}
                  ref={(node) => {
                    tabRefs.current[index] = node;
                  }}
                  type="button"
                  role="tab"
                  id={`${uid}-tab-${tab.id}`}
                  aria-selected={selected}
                  aria-controls={feedId}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setFilter(tab.id)}
                  onKeyDown={(event) => onTabKeyDown(event, index)}
                  className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${ring} ${
                    selected
                      ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                      : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
                  }`}
                >
                  {tab.label}
                  <span
                    className={`tabular-nums ${
                      selected
                        ? "text-slate-300 dark:text-slate-500"
                        : "text-slate-400 dark:text-slate-600"
                    }`}
                  >
                    {counts[tab.id]}
                  </span>
                </button>
              );
            })}
          </div>

          <div
            id={feedId}
            role="tabpanel"
            aria-labelledby={`${uid}-tab-${filter}`}
            tabIndex={-1}
            className="max-h-[30rem] overflow-y-auto px-5 py-2 sm:px-6"
          >
            {visible.length === 0 ? (
              <p className="px-1 py-14 text-center text-sm text-slate-500 dark:text-slate-400">
                Nothing here yet. Activity of this type will appear as your team works.
              </p>
            ) : (
              <ul role="log" aria-live="polite" aria-relevant="additions" className="relative">
                <AnimatePresence initial={false}>
                  {visible.map((item, index) => {
                    const isOpen = expanded.has(item.id);
                    const isUnread = unread.has(item.id);
                    const panelId = `${uid}-panel-${item.id}`;
                    return (
                      <motion.li
                        key={item.id}
                        layout={reduced ? false : "position"}
                        initial={reduced ? false : { opacity: 0, y: -12 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={reduced ? undefined : { opacity: 0 }}
                        transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
                        className="relative flex gap-3 py-3"
                      >
                        {index < visible.length - 1 && (
                          <span
                            aria-hidden="true"
                            className="absolute left-[19px] top-[52px] bottom-0 w-px bg-slate-200 dark:bg-slate-800"
                          />
                        )}

                        <span
                          className={`relative z-10 grid h-10 w-10 shrink-0 place-items-center rounded-xl ring-1 ${KIND[item.kind].badge}`}
                        >
                          <KindIcon kind={item.kind} />
                        </span>

                        <div className="min-w-0 flex-1">
                          <button
                            type="button"
                            onClick={() => toggle(item.id)}
                            aria-expanded={isOpen}
                            aria-controls={panelId}
                            className={`flex w-full items-start justify-between gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-slate-50 dark:hover:bg-slate-800/60 ${ring}`}
                          >
                            <span className="min-w-0">
                              <span className="block text-sm leading-snug text-slate-600 dark:text-slate-300">
                                <span className="font-semibold text-slate-900 dark:text-slate-50">
                                  {item.actor}
                                </span>{" "}
                                {item.action}{" "}
                                <span className="font-medium text-slate-800 dark:text-slate-100">
                                  {item.target}
                                </span>
                              </span>
                              <span className="mt-1 block truncate text-xs text-slate-400 dark:text-slate-500">
                                {item.meta}
                              </span>
                            </span>

                            <span className="flex shrink-0 items-center gap-2 pt-0.5">
                              {isUnread && (
                                <span
                                  className="h-1.5 w-1.5 rounded-full bg-indigo-500"
                                  title="Unread"
                                />
                              )}
                              <span className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
                                {formatAge(item.minutesAgo)}
                              </span>
                              <svg
                                width="14"
                                height="14"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="2.2"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                aria-hidden="true"
                                className={`text-slate-400 transition-transform duration-200 dark:text-slate-500 ${
                                  isOpen ? "rotate-180" : ""
                                }`}
                              >
                                <path d="m6 9 6 6 6-6" />
                              </svg>
                            </span>
                          </button>

                          <AnimatePresence initial={false}>
                            {isOpen && (
                              <motion.div
                                id={panelId}
                                key="panel"
                                initial={reduced ? false : { height: 0, opacity: 0 }}
                                animate={{ height: "auto", opacity: 1 }}
                                exit={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }}
                                transition={{ duration: 0.26, ease: [0.22, 1, 0.36, 1] }}
                                className="overflow-hidden"
                              >
                                <div className="mx-2 mb-1 mt-2 rounded-xl border border-slate-200/80 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-800/40">
                                  <div className="mb-2 flex items-center gap-2">
                                    <span className="grid h-5 w-5 place-items-center rounded-full bg-slate-200 text-[9px] font-bold text-slate-600 dark:bg-slate-700 dark:text-slate-300">
                                      {item.initials}
                                    </span>
                                    <span
                                      className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${KIND[item.kind].badge}`}
                                    >
                                      {KIND[item.kind].label}
                                    </span>
                                  </div>
                                  <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                                    {item.detail}
                                  </p>
                                </div>
                              </motion.div>
                            )}
                          </AnimatePresence>
                        </div>
                      </motion.li>
                    );
                  })}
                </AnimatePresence>
              </ul>
            )}
          </div>

          <footer className="border-t border-slate-200/80 px-5 py-3 sm:px-6 dark:border-slate-800">
            <div className="flex items-center justify-between gap-3">
              <p className="text-xs text-slate-500 dark:text-slate-400">
                Showing{" "}
                <span className="font-medium tabular-nums text-slate-700 dark:text-slate-200">
                  {visible.length}
                </span>{" "}
                of {items.length} events
              </p>
              {live && (
                <div
                  aria-hidden="true"
                  className="h-0.5 w-24 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
                >
                  <div className="dact-bar h-full w-full rounded-full bg-emerald-500/70" />
                </div>
              )}
            </div>
          </footer>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →