Dashboard Activity
Original · freerecent activity feed widget
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"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
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 quoteSimilar components
Browse all →
Dashboard KPI Row
Originalrow of KPI stat cards with trends

Dashboard Stat Widgets
Originalassorted dashboard stat widgets

Dashboard Chart Widget
Originalchart panel widget with tabs

Dashboard Revenue
Originalrevenue overview card with chart

Dashboard Users
Originalactive users widget

Dashboard Tasks
Originaltasks/checklist widget

Dashboard Calendar Widget
Originalmini calendar dashboard widget

Dashboard Notifications
Originalnotifications panel widget

Dashboard Overview
Originalcompact dashboard overview grid

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

