Display
Activity feed
Who did what, and when, as plain sentences with avatars, grouped by day and paged with Load more.
- Built on
<ol><time>role=status- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.3 kB
Code
import { ActivityFeed, ActivityFeedGroup, ActivityFeedItem } from "@/components/equip/activity-feed";
export default function Demo() {
return (
<ActivityFeed label="Repository activity" style={{ width: 216 }}>
<ActivityFeedGroup label="Today">
<ActivityFeedItem actor="Sam Lee" verb="merged" object="#482" href="#pulls/482" time="14:02" dateTime="2026-03-12T14:02" />
<ActivityFeedItem actor="Ana Ruiz" verb="opened" object="#483" href="#pulls/483" time="09:41" dateTime="2026-03-12T09:41" />
</ActivityFeedGroup>
</ActivityFeed>
);
}Examples
Grouped by day, with comments
Each day is a nested list named by its label, so a screen reader hears "Yesterday, list, 2 items".
Mira Okafor commented on Payment step
Can we keep the saved card selected by default? Most returning customers use the same one.Kwame Mensah moved Address autocomplete
In progress, then In review
Ingrid Holm approved Fraud checks spec
Tomás Ferreira joined the project
Code
import { ActivityFeed, ActivityFeedGroup, ActivityFeedItem } from "@/components/equip/activity-feed";
export default function Example() {
return (
<ActivityFeed label="Checkout redesign activity" style={{ maxWidth: 420 }}>
<ActivityFeedGroup label="Today">
<ActivityFeedItem actor="Mira Okafor" verb="commented on" object="Payment step" href="#designs/payment" time="12 min ago" dateTime="2026-03-12T13:50">
Can we keep the saved card selected by default? Most returning customers use the same one.
</ActivityFeedItem>
<ActivityFeedItem actor="Kwame Mensah" verb="moved" object="Address autocomplete" href="#tasks/88" time="1 hour ago" dateTime="2026-03-12T13:02">
In progress, then In review
</ActivityFeedItem>
</ActivityFeedGroup>
<ActivityFeedGroup label="Yesterday">
<ActivityFeedItem actor="Ingrid Holm" verb="approved" object="Fraud checks spec" href="#docs/fraud" time="17:30" dateTime="2026-03-11T17:30" />
<ActivityFeedItem actor="Tomás Ferreira" verb="joined the project" time="09:12" dateTime="2026-03-11T09:12" />
</ActivityFeedGroup>
</ActivityFeed>
);
}Load more
While loading, the button stays focusable and says Loading, and a hidden status line announces it, so keyboard focus is never lost.
Lena Vogel published Release notes 4.2 16:40
Omar Haddad closed Refund stuck in pending 11:05
Code
"use client";
import { useState } from "react";
import { ActivityFeed, ActivityFeedGroup, ActivityFeedItem } from "@/components/equip/activity-feed";
type Entry = { id: string; day: string; actor: string; verb: string; object: string; time: string };
const pages: Entry[][] = [
[
{ id: "a", day: "12 March", actor: "Lena Vogel", verb: "published", object: "Release notes 4.2", time: "16:40" },
{ id: "b", day: "12 March", actor: "Omar Haddad", verb: "closed", object: "Refund stuck in pending", time: "11:05" },
],
[
{ id: "c", day: "11 March", actor: "Yuki Tanaka", verb: "uploaded", object: "Onboarding flow v3", time: "15:22" },
{ id: "d", day: "11 March", actor: "Priya Raman", verb: "archived", object: "Old pricing page", time: "10:48" },
],
];
export default function Example() {
const [shown, setShown] = useState(1);
const [loading, setLoading] = useState(false);
const events = pages.slice(0, shown).flat();
const days = [...new Set(events.map((event) => event.day))];
function loadMore() {
setLoading(true);
window.setTimeout(() => {
setShown((count) => count + 1);
setLoading(false);
}, 900);
}
return (
<ActivityFeed
label="Workspace activity"
style={{ maxWidth: 420 }}
hasMore={shown < pages.length}
loading={loading}
onLoadMore={loadMore}
>
{days.map((day) => (
<ActivityFeedGroup key={day} label={day}>
{events
.filter((event) => event.day === day)
.map((event) => (
<ActivityFeedItem
key={event.id}
actor={event.actor}
verb={event.verb}
object={event.object}
href={`#activity/${event.id}`}
time={event.time}
/>
))}
</ActivityFeedGroup>
))}
</ActivityFeed>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | ActivityFeed: names the feed for assistive tech. |
hasMore | boolean | false | ActivityFeed: shows the Load more button at the end. |
onLoadMore | () => void | none | ActivityFeed: called when Load more is pressed; ignored while loading. |
loading | boolean | false | ActivityFeed: the next page is on its way. The button says Loading and stays focusable. |
moreLabel | string | "Load more" | ActivityFeed: text of the button. |
label | string | none | ActivityFeedGroup: the day, already formatted, such as Today or 12 March. |
actor | string | none | ActivityFeedItem: who did it. Also gives the avatar its initials. |
src | string | none | ActivityFeedItem: avatar image URL. |
verb | string | none | ActivityFeedItem: what they did, in lower case, such as commented on. |
object | ReactNode | none | ActivityFeedItem: what they did it to. |
href | string | none | ActivityFeedItem: makes the object a link. |
time | string | none | ActivityFeedItem: when, already formatted, such as 2 min ago. |
dateTime | string | none | ActivityFeedItem: ISO date for a time element. |
children | ReactNode | none | ActivityFeedItem: a quoted comment or diff summary under the sentence. |
...rest | ComponentProps<"div"> | ComponentProps<"li"> | none | ActivityFeed spreads onto its div; ActivityFeedGroup and ActivityFeedItem onto their li. |
Anatomy
| root | .eq-activity-feed | aria-busy while loading. |
|---|---|---|
| list | .eq-activity-feed-list | The ordered list, named by label. |
| group | .eq-activity-feed-group | |
| day | .eq-activity-feed-day | Visible day label; the nested list carries it as aria-label. |
| events | .eq-activity-feed-events | |
| item | .eq-activity-feed-item | |
| avatar | .eq-activity-feed-avatar | Hidden from assistive tech; the sentence names the actor. |
| main | .eq-activity-feed-main | |
| sentence | .eq-activity-feed-sentence | Actor, verb, object and time in one paragraph. |
| actor | .eq-activity-feed-actor | |
| object | .eq-activity-feed-object | A link when href is set. |
| time | .eq-activity-feed-time | A time element when dateTime is set. |
| body | .eq-activity-feed-body | |
| more | .eq-activity-feed-more | |
| load | .eq-activity-feed-load | The Load more Button. |
| status | .eq-activity-feed-status | Visually hidden polite status that says when more is loading. |
Keyboard
| EnterSpace | On Load more, fetches the next page; focus stays on the button. |
|---|
Notes
- Distinct from Timeline, which is a vertical rail of milestones and status changes with coloured markers and folded detail; this is people-centric activity written as sentences with avatars and paging.
- Each sentence reads as one line, Sam Lee merged #482 14:02, so it makes sense out of context in a screen reader's list.
- Days are nested ordered lists named by their label, so the list structure survives for screen readers.
- While loading, the button is aria-disabled rather than disabled, so keyboard focus is not lost, and a hidden status line announces it.
- Day labels and times are strings you pass in, so the server render stays deterministic.
- It is a client part only because Load more takes a handler; new events land with a short rise.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/activity-feed.tsx138 lines
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Avatar } from "./avatar";
import { Button } from "./button";
import "./activity-feed.css";
function cx(base: string, className: string | undefined) {
return [base, className].filter(Boolean).join(" ");
}
export type ActivityFeedProps = ComponentProps<"div"> & {
/** Names the feed for assistive tech, e.g. "Project activity". */
label: string;
/** Shows the Load more button at the end. */
hasMore?: boolean;
/** Called when Load more is pressed. Ignored while loading. */
onLoadMore?: () => void;
/** The next page is on its way: the button says so and stays focusable. */
loading?: boolean;
/** Text of the button. */
moreLabel?: string;
};
export function ActivityFeed({
label,
hasMore = false,
onLoadMore,
loading = false,
moreLabel = "Load more",
className,
children,
...rest
}: ActivityFeedProps) {
return (
<div {...rest} className={cx("eq-activity-feed", className)} aria-busy={loading || undefined}>
<ol className="eq-activity-feed-list" aria-label={label}>
{children}
</ol>
{(hasMore || loading) && (
<div className="eq-activity-feed-more">
<Button
size="s"
variant="outline"
className="eq-activity-feed-load"
// Loading keeps keyboard focus on the button and ignores presses until it is done.
loading={loading}
onClick={onLoadMore}
>
{loading ? "Loading" : moreLabel}
</Button>
</div>
)}
<span className="eq-activity-feed-status" role="status">
{loading ? `Loading more ${label.toLocaleLowerCase()}` : ""}
</span>
</div>
);
}
export type ActivityFeedGroupProps = Omit<ComponentProps<"li">, "children"> & {
/** The day the events belong to, already formatted, e.g. "Today" or "12 March". */
label: string;
children: ReactNode;
};
export function ActivityFeedGroup({ label, className, children, ...rest }: ActivityFeedGroupProps) {
return (
<li {...rest} className={cx("eq-activity-feed-group", className)}>
<div className="eq-activity-feed-day" aria-hidden="true">
{label}
</div>
<ol className="eq-activity-feed-events" aria-label={label}>
{children}
</ol>
</li>
);
}
export type ActivityFeedItemProps = Omit<ComponentProps<"li">, "object"> & {
/** Who did it. Also gives the avatar its initials. */
actor: string;
/** Avatar image URL for the actor. */
src?: string;
/** What they did, in lower case, e.g. "commented on" or "merged". */
verb: string;
/** What they did it to, e.g. "Checkout redesign". */
object?: ReactNode;
/** Makes the object a link. */
href?: string;
/** When, already formatted, e.g. "2 min ago". */
time: string;
/** ISO date for a time element, so machines can read it. */
dateTime?: string;
/** A quoted comment, a diff summary or other short detail. */
children?: ReactNode;
};
export function ActivityFeedItem({
actor,
src,
verb,
object,
href,
time,
dateTime,
className,
children,
...rest
}: ActivityFeedItemProps) {
return (
<li {...rest} className={cx("eq-activity-feed-item", className)}>
<Avatar className="eq-activity-feed-avatar" name={actor} src={src} alt="" size="s" />
<div className="eq-activity-feed-main">
<p className="eq-activity-feed-sentence">
<span className="eq-activity-feed-actor">{actor}</span> {verb}
{object != null && " "}
{object != null &&
(href ? (
<a className="eq-activity-feed-object" href={href}>
{object}
</a>
) : (
<span className="eq-activity-feed-object">{object}</span>
))}{" "}
{dateTime ? (
<time className="eq-activity-feed-time" dateTime={dateTime}>
{time}
</time>
) : (
<span className="eq-activity-feed-time">{time}</span>
)}
</p>
{children != null && <div className="eq-activity-feed-body">{children}</div>}
</div>
</li>
);
}components/equip/activity-feed.css132 lines
@layer equip {
.eq-activity-feed {
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-activity-feed-list,
.eq-activity-feed-events {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.eq-activity-feed-list {
gap: var(--eq-space-4);
}
.eq-activity-feed-events {
gap: var(--eq-space-3);
}
.eq-activity-feed-day {
margin-bottom: var(--eq-space-2);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
letter-spacing: 0.01em;
}
.eq-activity-feed-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: var(--eq-space-2);
align-items: start;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
/* A freshly loaded page of events lands rather than appears. */
@starting-style {
.eq-activity-feed-item {
opacity: 0;
translate: 0 4px;
}
}
.eq-activity-feed-main {
min-width: 0;
/* Lines the first line of text up with the middle of the 28px avatar. */
padding-top: 4px;
}
.eq-activity-feed-sentence {
margin: 0;
color: var(--eq-ink-2);
line-height: 20px;
overflow-wrap: anywhere;
}
.eq-activity-feed-actor {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-activity-feed-object {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
a.eq-activity-feed-object {
text-decoration: underline;
text-decoration-color: var(--eq-line-strong);
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
border-radius: 2px;
transition: text-decoration-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
a.eq-activity-feed-object:hover {
text-decoration-color: currentColor;
}
}
a.eq-activity-feed-object:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-activity-feed-time {
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* A quoted comment or a diff summary. */
.eq-activity-feed-body {
margin-top: var(--eq-space-1);
padding: var(--eq-space-2) var(--eq-space-3);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
color: var(--eq-ink);
overflow-wrap: anywhere;
}
.eq-activity-feed-body > :first-child {
margin-top: 0;
}
.eq-activity-feed-body > :last-child {
margin-bottom: 0;
}
.eq-activity-feed-more {
display: flex;
justify-content: center;
margin-top: var(--eq-space-4);
}
.eq-activity-feed-status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}