Activity feed
A team's day as rows of who did what to which thing, optionally split by day.
A client component. "Today" depends on when the page is read, which the server cannot know, so the day headings are absolute dates through the server render and the hydrating one and turn relative on the commit after — the same useSyncExternalStore guard countdown uses. Pass now to skip the wait: an explicit reference is deterministic on both sides. Date.now() is never read during render for anything that reaches the DOM. groupByDay and dayHeading are exported so a caller can build the same grouping elsewhere; groupByDay buckets by calendar day and keeps the order it was given, so a feed sorted newest-first stays that way. Each row is a list item, meta gets a bordered box of its own, and icon becomes a small badge on the actor's avatar. A row with no actor is a row nobody did — a threshold crossed, a job finished — so it drops the avatar column, carries its icon at the avatar's size in its place, and writes its action in the reader's ink rather than the secondary tier; live-feed is built on that shape. A day heading is a div, not a heading element — the feed cannot know what level it sits at — and names its own list through aria-label instead.
Install
npx shadcn@latest add @vibra/activity-feedNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
GitMergeIcon,
MessageSquareIcon,
RocketIcon,
TicketIcon,
UserPlusIcon,
} from "lucide-react"
import { ActivityFeed, type ActivityFeedItem } from "@/components/ui/activity-feed"
const MINUTE = 60_000
const HOUR = 3_600_000
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const items: ActivityFeedItem[] = [
{
id: "a1",
actor: { name: "Olivia Martin" },
action: "deployed",
target: "api-gateway",
icon: <RocketIcon />,
time: new Date(NOW - 12 * MINUTE),
meta: "Build 4c1a7f2 · 1m 48s · iad1, fra1, hnd1",
},
{
id: "a2",
actor: { name: "Jackson Lee" },
action: "merged",
target: "PR #482",
icon: <GitMergeIcon />,
time: new Date(NOW - 55 * MINUTE),
},
{
id: "a3",
actor: { name: "Isabella Nguyen" },
action: "commented on",
target: "VIB-471",
icon: <MessageSquareIcon />,
time: new Date(NOW - 3 * HOUR),
meta: "The index refresh is fine at 5 minutes — it is the cold start that costs us.",
},
{
id: "a4",
actor: { name: "William Kim" },
action: "opened",
target: "VIB-482",
icon: <TicketIcon />,
time: new Date(NOW - 27 * HOUR),
},
{
id: "a5",
actor: { name: "Sofia Davis" },
action: "invited",
target: "ethan.brooks@vibra.example",
icon: <UserPlusIcon />,
time: new Date(NOW - 30 * HOUR),
},
]
export default function ActivityFeedDemo() {
return (
<div className="w-full max-w-lg">
<ActivityFeed items={items} groupByDay now={NOW} />
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; actor?: { name: string; src?: string; fallback?: string }; action: React.ReactNode; target?: React.ReactNode; time: Date | string; meta?: React.ReactNode; icon?: React.ReactNode }[] | — | The events, rendered in the order given. |
| groupByDay | boolean | false | Splits the feed into sticky day headings — Today, Yesterday, then the date. |
| emptyMessage | React.ReactNode | No activity yet. | Shown in place of the list when there is nothing to report. |
| now | Date | string | number | the clock, after hydration | The moment "Today" is measured against. Pass it to keep headings deterministic. |
| groupByDay() | (items: ActivityFeedItem[]) => { key: string; date: Date; items: ActivityFeedItem[] }[] | — | Buckets items by calendar day, keeping the order they were given in. |
| dayHeading() | (date: Date, now: Date | null) => string | — | Names a day — "Today", "Yesterday", then the date; a null now means the date. |
Dependencies
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatDate, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { RelativeTime } from "@/components/ui/relative-time"
const DAY = 86_400_000
const toDate = (value: Date | string | number) => (value instanceof Date ? value : new Date(value))
// Midnight local, so two moments on the same calendar day compare equal even
// across a daylight-saving boundary.
const startOfDay = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
export type ActivityFeedItem = {
id: string
/**
* Who did it. Left out for a row nothing did — a threshold crossed, a job
* finished, a deploy landed — which drops the avatar column and lets the
* action itself open the line.
*/
actor?: { name: string; src?: string; fallback?: string }
action: React.ReactNode
target?: React.ReactNode
time: Date | string
/** A detail box under the row — a diff summary, a commit message, a note. */
meta?: React.ReactNode
/** A small badge on the actor's avatar saying what kind of event this is. */
icon?: React.ReactNode
}
export type ActivityFeedDayGroup = { key: string; date: Date; items: ActivityFeedItem[] }
/** Buckets items by calendar day, keeping the order they were given in. */
export function groupByDay(items: ActivityFeedItem[]): ActivityFeedDayGroup[] {
const groups: ActivityFeedDayGroup[] = []
const byKey = new Map<string, ActivityFeedDayGroup>()
for (const item of items) {
const date = toDate(item.time)
const key = String(startOfDay(date))
const existing = byKey.get(key)
if (existing) {
existing.items.push(item)
continue
}
const group: ActivityFeedDayGroup = { key, date, items: [item] }
byKey.set(key, group)
groups.push(group)
}
return groups
}
/**
* Names a day relative to `now` — "Today", "Yesterday", then the date itself.
* A null `now` means the clock is not readable yet, and the date is used.
*/
export function dayHeading(date: Date, now: Date | null): string {
if (now === null) return formatDate(date, "medium")
const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY)
if (days === 0) return "Today"
if (days === 1) return "Yesterday"
return formatDate(date, "medium")
}
// Nothing to subscribe to: the store exists only to hand the server and the
// client different snapshots — the same pattern countdown and use-media-query
// use to reveal a browser-only value after hydration.
const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false
/** False through the server render and the hydrating one, true from the commit on. */
function useHasClock(): boolean {
return React.useSyncExternalStore(subscribeToNothing, onClient, onServer)
}
export type ActivityFeedProps = React.ComponentProps<"div"> & {
items: ActivityFeedItem[]
/** Splits the feed into sticky day headings. */
groupByDay?: boolean
emptyMessage?: React.ReactNode
/** The moment "Today" is measured against. Pass it to keep headings deterministic. */
now?: Date | string | number
}
/** A team's day as a list of rows: who did what to which thing, and when. */
function ActivityFeed({
className,
items,
groupByDay: grouped = false,
emptyMessage = "No activity yet.",
now,
...props
}: ActivityFeedProps) {
// "Today" depends on when the page is read, which the server cannot know.
// Until the clock is readable the headings are absolute dates, so the
// hydrating markup matches the server's exactly; an explicit `now` is
// deterministic on both sides and skips the wait.
const hasClock = useHasClock()
const [mountedNow] = React.useState(() => new Date())
const reference = now !== undefined ? toDate(now) : hasClock ? mountedNow : null
// The stamps take the same reference, so a row never reads "2d ago" under a
// "Today" heading.
const rowNow = now !== undefined ? toDate(now) : undefined
const groups = grouped
? groupByDay(items)
: [{ key: "all", date: mountedNow, items } satisfies ActivityFeedDayGroup]
return (
<div
data-slot="activity-feed"
data-grouped={grouped || undefined}
className={cn("flex w-full flex-col", className)}
{...props}
>
{items.length === 0 ? (
<p data-slot="activity-feed-empty" className="py-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</p>
) : (
groups.map((group) => (
<React.Fragment key={group.key}>
{grouped ? (
// A div, not a heading: the feed cannot know what level it sits
// at. The day names the list under it instead.
<div
data-slot="activity-feed-day"
className="sticky top-0 z-10 bg-background/85 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur-sm"
>
{dayHeading(group.date, reference)}
</div>
) : null}
<ol
aria-label={grouped ? dayHeading(group.date, reference) : undefined}
className="flex flex-col"
>
{group.items.map((item) => (
<ActivityFeedRow now={rowNow} key={item.id} item={item} />
))}
</ol>
</React.Fragment>
))
)}
</div>
)
}
function ActivityFeedRow({ item, now }: { item: ActivityFeedItem; now?: Date }) {
const { actor } = item
return (
<li
data-slot="activity-feed-item"
data-system={actor ? undefined : true}
className="flex gap-3 py-2.5"
>
{actor ? (
<span className="relative shrink-0">
<Avatar className="size-7">
{actor.src ? <AvatarImage src={actor.src} alt="" /> : null}
<AvatarFallback className="text-avatar">
{actor.fallback ?? getInitials(actor.name)}
</AvatarFallback>
</Avatar>
{item.icon ? (
<span
data-slot="activity-feed-item-icon"
aria-hidden="true"
className="absolute -end-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-background text-muted-foreground ring-2 ring-background [&_svg]:size-2.5"
>
{item.icon}
</span>
) : null}
</span>
) : item.icon ? (
// No one to badge, so the glyph stands in the avatar's place at the
// avatar's size rather than clinging to its corner.
<span
data-slot="activity-feed-item-icon"
aria-hidden="true"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground [&_svg]:size-3.5"
>
{item.icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 text-sm">
{actor ? (
<span data-slot="activity-feed-item-actor" className="font-medium">
{actor.name}
</span>
) : null}
{/* With no name in front of it the action is the line, so it is
written in the reader's ink rather than the secondary tier. */}
<span className={actor ? "text-muted-foreground" : "min-w-0"}>{item.action}</span>
{item.target ? (
<span data-slot="activity-feed-item-target" className="font-medium">
{item.target}
</span>
) : null}
<RelativeTime
now={now}
date={item.time}
className="ms-auto shrink-0 text-xs text-muted-foreground"
/>
</div>
{item.meta ? (
<div
data-slot="activity-feed-item-meta"
className="rounded-md border bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground"
>
{item.meta}
</div>
) : null}
</div>
</li>
)
}
export { ActivityFeed }