Activity Timeline
Original · freeactivity feed timeline
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/tl-activity.json"use client";
import { useId, useMemo, useState, type ReactElement } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ActivityKind =
| "deploy"
| "review"
| "commit"
| "comment"
| "incident"
| "release";
type DayKey = "today" | "yesterday";
interface MetaItem {
label: string;
value: string;
}
interface Activity {
id: string;
kind: ActivityKind;
actor: string;
verb: string;
target: string;
context?: string;
time: string;
datetime: string;
day: DayKey;
detail: string;
meta: MetaItem[];
}
interface KindConfig {
label: string;
single: string;
Icon: (props: { className?: string }) => ReactElement;
node: string;
tag: string;
chipOn: string;
dot: string;
}
function DeployIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
);
}
function ReviewIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="6" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
<line x1="6" y1="9" x2="6" y2="21" />
</svg>
);
}
function CommitIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="12" r="3.2" />
<line x1="3" y1="12" x2="8.8" y2="12" />
<line x1="15.2" y1="12" x2="21" y2="12" />
</svg>
);
}
function CommentIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z" />
</svg>
);
}
function IncidentIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
function ReleaseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
const KIND: Record<ActivityKind, KindConfig> = {
deploy: {
label: "Deploys",
single: "Deploy",
Icon: DeployIcon,
node: "border-emerald-500/40 bg-emerald-500/15 text-emerald-600 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-300",
tag: "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300",
chipOn:
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-300",
dot: "bg-emerald-500 dark:bg-emerald-400",
},
review: {
label: "Reviews",
single: "Review",
Icon: ReviewIcon,
node: "border-violet-500/40 bg-violet-500/15 text-violet-600 dark:border-violet-400/40 dark:bg-violet-400/15 dark:text-violet-300",
tag: "bg-violet-500/10 text-violet-700 dark:bg-violet-400/10 dark:text-violet-300",
chipOn:
"border-violet-500/40 bg-violet-500/10 text-violet-700 dark:border-violet-400/40 dark:bg-violet-400/10 dark:text-violet-300",
dot: "bg-violet-500 dark:bg-violet-400",
},
commit: {
label: "Commits",
single: "Commit",
Icon: CommitIcon,
node: "border-indigo-500/40 bg-indigo-500/15 text-indigo-600 dark:border-indigo-400/40 dark:bg-indigo-400/15 dark:text-indigo-300",
tag: "bg-indigo-500/10 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-300",
chipOn:
"border-indigo-500/40 bg-indigo-500/10 text-indigo-700 dark:border-indigo-400/40 dark:bg-indigo-400/10 dark:text-indigo-300",
dot: "bg-indigo-500 dark:bg-indigo-400",
},
comment: {
label: "Comments",
single: "Comment",
Icon: CommentIcon,
node: "border-sky-500/40 bg-sky-500/15 text-sky-600 dark:border-sky-400/40 dark:bg-sky-400/15 dark:text-sky-300",
tag: "bg-sky-500/10 text-sky-700 dark:bg-sky-400/10 dark:text-sky-300",
chipOn:
"border-sky-500/40 bg-sky-500/10 text-sky-700 dark:border-sky-400/40 dark:bg-sky-400/10 dark:text-sky-300",
dot: "bg-sky-500 dark:bg-sky-400",
},
incident: {
label: "Incidents",
single: "Incident",
Icon: IncidentIcon,
node: "border-rose-500/40 bg-rose-500/15 text-rose-600 dark:border-rose-400/40 dark:bg-rose-400/15 dark:text-rose-300",
tag: "bg-rose-500/10 text-rose-700 dark:bg-rose-400/10 dark:text-rose-300",
chipOn:
"border-rose-500/40 bg-rose-500/10 text-rose-700 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-300",
dot: "bg-rose-500 dark:bg-rose-400",
},
release: {
label: "Releases",
single: "Release",
Icon: ReleaseIcon,
node: "border-amber-500/40 bg-amber-500/15 text-amber-600 dark:border-amber-400/40 dark:bg-amber-400/15 dark:text-amber-300",
tag: "bg-amber-500/10 text-amber-700 dark:bg-amber-400/10 dark:text-amber-300",
chipOn:
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-300",
dot: "bg-amber-500 dark:bg-amber-400",
},
};
const KIND_ORDER: ActivityKind[] = [
"deploy",
"review",
"commit",
"comment",
"incident",
"release",
];
const DATA: Activity[] = [
{
id: "a1",
kind: "deploy",
actor: "Maya Chen",
verb: "deployed",
target: "api-gateway",
context: " to production",
time: "2m",
datetime: "2026-07-16T14:58:00",
day: "today",
detail:
"Rolling deploy finished across three regions with no downtime. The canary held at 5% traffic for four minutes and the error budget was untouched.",
meta: [
{ label: "Build", value: "#4821" },
{ label: "Duration", value: "42s" },
{ label: "Services", value: "3" },
],
},
{
id: "a2",
kind: "review",
actor: "Diego Ferreira",
verb: "opened",
target: "#218",
context: " · Rate-limit the webhook ingest queue",
time: "14m",
datetime: "2026-07-16T14:46:00",
day: "today",
detail:
"Adds a token-bucket limiter in front of the ingest workers so a single noisy tenant can no longer starve the shared queue for everyone else.",
meta: [
{ label: "Changes", value: "+312 / −47" },
{ label: "Files", value: "8" },
{ label: "Reviewers", value: "2" },
],
},
{
id: "a3",
kind: "incident",
actor: "PagerDuty",
verb: "resolved",
target: "5xx spike",
context: " on payments-svc",
time: "41m",
datetime: "2026-07-16T14:19:00",
day: "today",
detail:
"Errors traced to an exhausted connection pool. Mitigated by raising the pool ceiling and cycling two pods; monitoring confirmed a full recovery within the window.",
meta: [
{ label: "Severity", value: "SEV-2" },
{ label: "Impact", value: "11m" },
{ label: "Owner", value: "Sam O." },
],
},
{
id: "a4",
kind: "comment",
actor: "Priya Nair",
verb: "commented on",
target: "#196",
context: " · Checkout latency",
time: "1h",
datetime: "2026-07-16T13:52:00",
day: "today",
detail:
"“Traced the p95 to the tax lookup — it’s making a synchronous call per line item. Batching it drops the call count from 12 to 1 on a typical cart.”",
meta: [{ label: "Thread", value: "6 replies" }],
},
{
id: "a5",
kind: "commit",
actor: "Sam Okafor",
verb: "pushed",
target: "6 commits",
context: " to main",
time: "2h",
datetime: "2026-07-16T12:40:00",
day: "today",
detail:
"fix: batch the tax lookup per cart · perf: cache the currency table · test: cover partial refunds · chore: bump the sdk to 4.2 · … and 2 more.",
meta: [
{ label: "Commits", value: "6" },
{ label: "Branch", value: "main" },
],
},
{
id: "a6",
kind: "release",
actor: "Release Bot",
verb: "published",
target: "v3.8.0",
time: "18h",
datetime: "2026-07-15T20:30:00",
day: "yesterday",
detail:
"Highlights: OIDC single sign-on, roughly 30% faster cold starts, and a redesigned audit log. The full notes are in the changelog.",
meta: [
{ label: "Tag", value: "v3.8.0" },
{ label: "Commits", value: "84" },
],
},
{
id: "a7",
kind: "review",
actor: "Lena Vogt",
verb: "approved",
target: "#214",
context: " · Migrate auth to OIDC",
time: "21h",
datetime: "2026-07-15T17:10:00",
day: "yesterday",
detail:
"Approved after the second pass — the token-refresh edge cases are covered now and the rollback path is documented in the runbook.",
meta: [
{ label: "Status", value: "Approved" },
{ label: "Files", value: "23" },
],
},
{
id: "a8",
kind: "deploy",
actor: "Sam Okafor",
verb: "rolled back",
target: "checkout-web",
context: " on staging",
time: "22h",
datetime: "2026-07-15T16:05:00",
day: "yesterday",
detail:
"Reverted to build #4790 after a layout regression on the payment step. The fix is tracked in #221 and re-deploy is scheduled once it lands.",
meta: [
{ label: "From", value: "#4802" },
{ label: "To", value: "#4790" },
],
},
{
id: "a9",
kind: "comment",
actor: "Maya Chen",
verb: "mentioned you in",
target: "#eng-releases",
time: "23h",
datetime: "2026-07-15T15:20:00",
day: "yesterday",
detail:
"“Great work shipping OIDC — can you write up the migration steps for the customer-facing docs before Friday? Happy to review a draft.”",
meta: [{ label: "Channel", value: "#eng-releases" }],
},
];
const DAY_LABEL: Record<DayKey, string> = {
today: "Today",
yesterday: "Yesterday",
};
const STYLES = `
.tlact-enter{animation:tlact-fade-up 520ms cubic-bezier(0.22,1,0.36,1) both}
@keyframes tlact-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.tlact-pulse{animation:tlact-pulse 2.1s ease-in-out infinite}
@keyframes tlact-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
@media (prefers-reduced-motion:reduce){.tlact-enter{animation:none}.tlact-pulse{animation:none}}
`;
export default function ActivityFeedTimeline() {
const reduce = useReducedMotion();
const baseId = useId();
const [active, setActive] = useState<Set<ActivityKind>>(
() => new Set(KIND_ORDER),
);
const [open, setOpen] = useState<Set<string>>(() => new Set(["a1"]));
const counts = useMemo(() => {
const c: Record<ActivityKind, number> = {
deploy: 0,
review: 0,
commit: 0,
comment: 0,
incident: 0,
release: 0,
};
for (const a of DATA) c[a.kind] += 1;
return c;
}, []);
const visible = useMemo(
() => DATA.filter((a) => active.has(a.kind)),
[active],
);
const groups = useMemo(() => {
return (["today", "yesterday"] as DayKey[])
.map((day) => ({
day,
label: DAY_LABEL[day],
items: visible.filter((a) => a.day === day),
}))
.filter((g) => g.items.length > 0);
}, [visible]);
const allActive = active.size === KIND_ORDER.length;
function toggleKind(kind: ActivityKind) {
setActive((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}
function toggleOpen(id: string) {
setOpen((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950";
return (
<section
aria-labelledby={`${baseId}-title`}
className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 lg:py-24 dark:bg-slate-950"
>
<style>{STYLES}</style>
<div className="mx-auto max-w-3xl">
<header className="mb-8">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h2
id={`${baseId}-title`}
className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
>
Team activity
</h2>
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-700 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-300">
<span className="relative flex h-2 w-2">
<span className="tlact-pulse absolute inline-flex h-full w-full rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
</span>
Live
</span>
</div>
<p className="mt-2 max-w-prose text-sm text-slate-600 sm:text-base dark:text-slate-400">
Everything happening across the platform team — deploys, reviews,
commits and incidents in one stream. Filter by type or expand an
event for the details.
</p>
</header>
<div
role="group"
aria-label="Filter activity by type"
className="mb-2 flex flex-wrap items-center gap-2"
>
{KIND_ORDER.map((kind) => {
const cfg = KIND[kind];
const on = active.has(kind);
return (
<button
key={kind}
type="button"
aria-pressed={on}
onClick={() => toggleKind(kind)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${focusRing} ${
on
? cfg.chipOn
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-100 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
<span
aria-hidden="true"
className={`h-2 w-2 rounded-full ${
on ? cfg.dot : "bg-slate-300 dark:bg-slate-600"
}`}
/>
{cfg.label}
<span
className={`ml-0.5 tabular-nums ${
on
? "opacity-70"
: "text-slate-400 dark:text-slate-500"
}`}
>
{counts[kind]}
</span>
</button>
);
})}
<button
type="button"
onClick={() => setActive(new Set(KIND_ORDER))}
disabled={allActive}
className={`ml-auto rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${focusRing} ${
allActive
? "cursor-not-allowed text-slate-300 dark:text-slate-700"
: "text-slate-600 underline decoration-slate-300 underline-offset-4 hover:text-slate-900 dark:text-slate-400 dark:decoration-slate-600 dark:hover:text-white"
}`}
>
Show all
</button>
</div>
<p aria-live="polite" className="mb-6 text-xs text-slate-500 dark:text-slate-500">
Showing {visible.length} of {DATA.length} events
</p>
{groups.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 bg-white/50 px-6 py-14 text-center dark:border-slate-700 dark:bg-slate-900/40">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
No activity matches these filters
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Turn a type back on to see events again.
</p>
<button
type="button"
onClick={() => setActive(new Set(KIND_ORDER))}
className={`mt-4 inline-flex rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 ${focusRing}`}
>
Reset filters
</button>
</div>
) : (
<ol className="space-y-8">
{groups.map((group) => (
<li key={group.day}>
<div className="mb-4 flex items-center gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{group.label}
</h3>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-800" />
<span className="text-xs tabular-nums text-slate-400 dark:text-slate-500">
{group.items.length}
</span>
</div>
<ul className="relative space-y-3">
<span
aria-hidden="true"
className="absolute left-5 top-3 bottom-3 w-px bg-slate-200 dark:bg-slate-800"
/>
{group.items.map((activity, index) => {
const cfg = KIND[activity.kind];
const isOpen = open.has(activity.id);
const panelId = `${baseId}-panel-${activity.id}`;
const Icon = cfg.Icon;
return (
<li
key={activity.id}
className="tlact-enter relative pl-14"
style={{ animationDelay: `${index * 55}ms` }}
>
<span
className={`absolute left-0 top-0 flex h-10 w-10 items-center justify-center rounded-full border ring-4 ring-slate-50 dark:ring-slate-950 ${cfg.node}`}
>
<Icon className="h-[18px] w-[18px]" />
</span>
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm transition-colors hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700">
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggleOpen(activity.id)}
className={`flex w-full items-start gap-3 rounded-2xl p-4 text-left ${focusRing}`}
>
<span className="min-w-0 flex-1">
<span className="block text-sm leading-relaxed text-slate-600 dark:text-slate-300">
<span className="font-semibold text-slate-900 dark:text-white">
{activity.actor}
</span>{" "}
{activity.verb}{" "}
<span className="font-semibold text-slate-800 dark:text-slate-100">
{activity.target}
</span>
{activity.context ? (
<span className="text-slate-500 dark:text-slate-400">
{activity.context}
</span>
) : null}
</span>
<span className="mt-2 flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${cfg.tag}`}
>
{cfg.single}
</span>
<time
dateTime={activity.datetime}
className="text-xs text-slate-400 dark:text-slate-500"
>
{activity.time} ago
</time>
</span>
</span>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-slate-400 dark:text-slate-500">
<ChevronIcon
className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${
isOpen ? "rotate-180" : "rotate-0"
}`}
/>
</span>
</button>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
key="panel"
id={panelId}
initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={
reduce
? { opacity: 1 }
: { height: "auto", opacity: 1 }
}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{
duration: reduce ? 0 : 0.28,
ease: [0.22, 1, 0.36, 1],
}}
className="overflow-hidden"
>
<div className="border-t border-slate-100 px-4 pb-4 pt-3 dark:border-slate-800">
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{activity.detail}
</p>
{activity.meta.length > 0 ? (
<dl className="mt-3 flex flex-wrap gap-2">
{activity.meta.map((m) => (
<div
key={m.label}
className="inline-flex items-center gap-1.5 rounded-md bg-slate-100 px-2 py-1 text-xs dark:bg-slate-800"
>
<dt className="text-slate-500 dark:text-slate-400">
{m.label}
</dt>
<dd className="font-medium tabular-nums text-slate-800 dark:text-slate-100">
{m.value}
</dd>
</div>
))}
</dl>
) : null}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</li>
);
})}
</ul>
</li>
))}
</ol>
)}
{groups.length > 0 ? (
<div className="relative mt-6 pl-14">
<span
aria-hidden="true"
className="absolute left-5 top-0 flex h-10 w-10 -translate-x-px items-center justify-center"
>
<span className="h-2.5 w-2.5 rounded-full border-2 border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-950" />
</span>
<p className="pt-1.5 text-sm text-slate-500 dark:text-slate-500">
You’re all caught up.
</p>
</div>
) : null}
</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 →
Vertical Timeline
Originalvertical timeline with dots

Centered Timeline
Originalcentred alternating timeline
Icons Timeline
Originaltimeline with icon nodes

Cards Timeline
Originaltimeline with card entries

Horizontal Timeline
Originalhorizontal timeline

Gradient Timeline
Originaltimeline with a gradient line

Roadmap Timeline
Originalproduct roadmap timeline

Numbered Timeline
Originalnumbered milestone timeline

Compact Timeline
Originalcompact log timeline

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.

