A day-grouped team activity stream where simulated live events blur-rise in at the top without shifting the rest of the list.
npx shadcn@latest add @paragon/activity-feed"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>
);
}