Activity Feed Page
The Activity Feed Page is a high-level template for displaying chronological events such as user actions, system updates, billing events, and security alerts. It provides:
- A chronological event list, sorted newest-first.
- Event type icons and badges for quick scanning (auth, user, billing, security, etc.).
- Actor information (who performed the action).
- Relative or absolute timestamps (e.g.
"2 hours ago"or"Mar 12, 2026, 08:45"). - Filter by event type chips and search across titles, descriptions, actors, and context.
- Either pagination or infinite load-more behaviour.
The page is composable: you can use the full ActivityFeedPage component, or mix and match layout, header, filters, list, row, and pagination building blocks to fit your dashboard.
- Preview
- Code
Activity Feed
Track recent actions, system updates, and user activity.
Events
Newest first · Filter by type · Search across titles, actors, and descriptions
User signed in
Event typeAuthSuccessful login from a trusted device. (event 1)
New user invited
Event typeUserAn invitation was sent to join the workspace. (event 2)
Security policy updated
Event typeSecurityMFA enforcement was enabled for all admins. (event 3)
System configuration changed
Event typeSystemUpdated environment variables for the deployment. (event 4)
Invoice paid
Event typeBillingPayment succeeded for the monthly subscription. (event 5)
Order processed
Event typeOrderOrder moved to the next stage in fulfillment. (event 6)
Document updated
Event typeDocumentEdited a shared SOP for onboarding. (event 8)
Comment added
Event typeCommentLeft feedback on the latest deployment notes. (event 7)
Maintenance scheduled
Event typeSchedulePlanned maintenance window was created. (event 9)
Unusual activity detected
Event typeWarningMultiple failed sign-in attempts were detected. (event 10)
import {
ActivityFeedPage,
type ActivityEvent,
type ActivityFeedFilterState,
} from "@ignix-ui/activity-feed-page";
const events: ActivityEvent[] = [
{
id: "1",
type: "authentication",
actor: { name: "Alex Morgan", meta: "Admin" },
occurredAt: new Date(),
title: "User signed in",
description: "Successful login from a trusted device.",
},
// ...more ActivityEvent rows
];
export function Example() {
const [filter, setFilter] = useState<ActivityFeedFilterState>({
type: null,
query: "",
});
return (
<ActivityFeedPage
events={events}
filterState={filter}
onFilterChange={setFilter}
pagingMode="pagination"
pageSize={10}
/>
);
}
Installation
- CLI
- Manual
ignix add component activity-feed-page
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityLogIcon,
CalendarIcon,
CheckCircledIcon,
FileTextIcon,
GearIcon,
LightningBoltIcon,
LockClosedIcon,
Pencil2Icon,
PersonIcon,
RocketIcon,
} from "@radix-ui/react-icons";
import { cn } from "../../../utils/cn";
import { Button } from "@ignix-ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ignix-ui/card";
import { AnimatedInput } from "@ignix-ui/input";
import { Pagination } from "@ignix-ui/pagination";
// =============================================================================
// TYPES
// =============================================================================
/**
* Supported event types for the activity feed.
*/
export type ActivityEventType =
| "authentication"
| "user"
| "security"
| "system"
| "billing"
| "order"
| "comment"
| "document"
| "schedule"
| "warning";
/**
* Minimal actor (user/system) shown for each event.
*/
export interface ActivityActor {
/** Display name for the actor. */
name: string;
/** Optional short handle/role (e.g. "Admin", "@sara"). */
meta?: string;
/** Optional avatar URL. If omitted, initials avatar is shown. */
avatarUrl?: string;
}
/**
* A single activity event in the feed.
*/
export interface ActivityEvent {
/** Unique id. */
id: string;
/** Event type used for icons, filters, and badges. */
type: ActivityEventType;
/** Actor performing the action (user/service). */
actor: ActivityActor;
/** When the event occurred (Date). */
occurredAt: Date;
/** Short, human readable title. */
title: string;
/** Primary description text. */
description: string;
/** Optional secondary metadata shown on the right (e.g. "Invoice #1024"). */
contextLabel?: string;
}
/**
* Timestamp display mode.
*/
export type TimestampMode = "relative" | "absolute";
/**
* Paging behaviour for the feed.
*/
export type FeedPagingMode = "pagination" | "infinite";
/**
* Filter state for the feed.
*/
export interface ActivityFeedFilterState {
/** Selected event type; null means "All". */
type: ActivityEventType | null;
/** Search query applied to title/description/actor. */
query: string;
}
/**
* Props for the ActivityFeedPage template.
*/
export interface ActivityFeedPageProps {
/** Events list (will be sorted newest-first internally). */
events: ActivityEvent[];
/** Optional title shown in header. */
title?: string;
/** Optional description shown under title. */
description?: string;
/** Timestamp display mode. */
timestampMode?: TimestampMode;
/** Paging mode: numbered pagination or infinite "load more". */
pagingMode?: FeedPagingMode;
/** Items per page / per load in paging mode. */
pageSize?: number;
/** Optional external filter state (controlled). */
filterState?: ActivityFeedFilterState;
/** Callback fired when filter/search changes. */
onFilterChange?: (next: ActivityFeedFilterState) => void;
/** Optional className for root container. */
className?: string;
}
/**
* Props for the high-level layout wrapper (gradient background + max-width content).
*/
export interface ActivityFeedLayoutProps {
/** Page content (header, filters, list, etc.). */
children: React.ReactNode;
/** Optional className for the outermost wrapper. */
className?: string;
}
/**
* Props for the page header (title + description + search input).
*/
export interface ActivityFeedHeaderProps {
/** Page title (e.g. "Activity Feed"). */
title?: string;
/** Optional subtitle/description. */
description?: string;
/** Current search query value. */
query: string;
/** Change handler for the search query. */
onQueryChange: (value: string) => void;
}
/**
* Props for the filters section (event type chips).
*/
export interface ActivityFeedFiltersProps {
/** Full, sorted list of events (used to compute type counts). */
events: ActivityEvent[];
/** Current filter state (type + query). */
filter: ActivityFeedFilterState;
/** Change handler when type filter changes. */
onFilterChange: (next: ActivityFeedFilterState) => void;
}
/**
* Props for the grouped list section.
*/
export interface ActivityFeedListProps {
/** Already-filtered and paged events to display. */
events: ActivityEvent[];
/** Timestamp rendering mode. */
timestampMode: TimestampMode;
/** Reference "now" for relative times. */
now: Date;
}
/**
* Props for pagination / infinite scroll controls.
*/
export interface ActivityFeedPaginationProps {
/** Paging behaviour: numbered pages or infinite "load more". */
pagingMode: FeedPagingMode;
/** Current page (pagination mode). */
currentPage: number;
/** Total pages (pagination mode). */
totalPages: number;
/** Whether more items can be loaded (infinite mode). */
canLoadMore: boolean;
/** Callback when page changes (pagination mode). */
onPageChange: (page: number) => void;
/** Callback when more items should be loaded (infinite mode). */
onLoadMore: () => void;
}
// =============================================================================
// DEFAULTS
// =============================================================================
/** Default items per page. */
const DEFAULT_PAGE_SIZE = 10;
/** Fixed ordering for filter pills. */
const EVENT_TYPE_ORDER: ActivityEventType[] = [
"authentication",
"user",
"security",
"system",
"billing",
"order",
"comment",
"document",
"schedule",
"warning",
];
/** Human labels for event types. */
const EVENT_TYPE_LABEL: Record<ActivityEventType, string> = {
authentication: "Auth",
user: "User",
security: "Security",
system: "System",
billing: "Billing",
order: "Order",
comment: "Comment",
document: "Document",
schedule: "Schedule",
warning: "Warning",
};
/** Badge intent mapping for event type. */
const EVENT_TYPE_BADGE_TYPE: Record<
ActivityEventType,
"primary" | "secondary" | "success" | "warning" | "error"
> = {
authentication: "secondary",
user: "primary",
security: "warning",
system: "secondary",
billing: "success",
order: "primary",
comment: "secondary",
document: "secondary",
schedule: "primary",
warning: "error",
};
// =============================================================================
// HELPERS
// =============================================================================
/**
* Returns pill classes for the inline event type badge.
*/
function getEventTypePillClasses(
kind: "primary" | "secondary" | "success" | "warning" | "error",
): string {
switch (kind) {
case "success":
return "border-success/25 bg-success/10 text-success";
case "warning":
return "border-warning/30 bg-warning/10 text-warning";
case "error":
return "border-destructive/25 bg-destructive/10 text-destructive";
case "secondary":
return "border-border/60 bg-muted/40 text-muted-foreground";
case "primary":
return "border-primary/25 bg-primary/10 text-primary";
}
}
/**
* Accent classes per event type to improve visual scanability in both themes.
*/
function getEventTypeAccentClasses(type: ActivityEventType): {
iconChip: string;
rowAccent: string;
dotAccent: string;
} {
switch (type) {
case "billing":
return {
iconChip: "border-success/30 bg-success/10 text-success",
rowAccent: "border-l-success/50",
dotAccent: "bg-success/80",
};
case "security":
return {
iconChip: "border-warning/35 bg-warning/10 text-warning",
rowAccent: "border-l-warning/60",
dotAccent: "bg-warning/80",
};
case "warning":
return {
iconChip: "border-destructive/30 bg-destructive/10 text-destructive",
rowAccent: "border-l-destructive/60",
dotAccent: "bg-destructive/80",
};
case "user":
case "order":
case "schedule":
return {
iconChip: "border-primary/30 bg-primary/10 text-primary",
rowAccent: "border-l-primary/55",
dotAccent: "bg-primary/80",
};
case "authentication":
case "system":
case "comment":
case "document":
return {
iconChip: "border-border/60 bg-muted/40 text-muted-foreground",
rowAccent: "border-l-cyan-400/60 dark:border-l-cyan-300/55",
dotAccent: "bg-cyan-500/80 dark:bg-cyan-300/80",
};
}
}
/**
* Returns a stable, case-insensitive query match against event fields.
*/
function matchesQuery(event: ActivityEvent, query: string): boolean {
const q = query.trim().toLowerCase();
if (q.length === 0) return true;
const haystack = [
event.title,
event.description,
event.actor.name,
event.actor.meta ?? "",
EVENT_TYPE_LABEL[event.type],
event.contextLabel ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
}
/**
* Formats a Date as an absolute timestamp, readable and locale-aware.
*/
function formatAbsolute(date: Date): string {
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Formats a Date into a relative timestamp like "2 hours ago".
* Uses coarse units to keep UI scannable.
*/
function formatRelative(date: Date, now: Date): string {
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.max(0, Math.floor(diffMs / 1000));
const minutes = Math.floor(diffSec / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (diffSec < 45) return "just now";
if (minutes < 60) return `${minutes} min${minutes === 1 ? "" : "s"} ago`;
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;
if (weeks < 5) return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
return `${years} year${years === 1 ? "" : "s"} ago`;
}
/**
* Returns a short date group label for the feed (e.g. "Today", "Yesterday", "Mar 12, 2026").
*/
function getDateGroupLabel(date: Date, now: Date): string {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const startOfThatDay = new Date(date);
startOfThatDay.setHours(0, 0, 0, 0);
const diffDays = Math.round(
(startOfToday.getTime() - startOfThatDay.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
});
}
/**
* Returns an icon component for an event type.
*/
function EventTypeIcon({ type }: { type: ActivityEventType }) {
const className = "h-4 w-4";
switch (type) {
case "authentication":
return <LockClosedIcon className={className} aria-hidden />;
case "user":
return <PersonIcon className={className} aria-hidden />;
case "security":
return <ActivityLogIcon className={className} aria-hidden />;
case "system":
return <GearIcon className={className} aria-hidden />;
case "billing":
return <CheckCircledIcon className={className} aria-hidden />;
case "order":
return <RocketIcon className={className} aria-hidden />;
case "comment":
return <Pencil2Icon className={className} aria-hidden />;
case "document":
return <FileTextIcon className={className} aria-hidden />;
case "schedule":
return <CalendarIcon className={className} aria-hidden />;
case "warning":
return <LightningBoltIcon className={className} aria-hidden />;
}
}
/**
* Inline event type pill (icon + label) designed for feed rows.
*/
const EventTypePill = React.memo(function EventTypePill({
type,
}: {
type: ActivityEventType;
}) {
const badgeType = EVENT_TYPE_BADGE_TYPE[type];
const accent = getEventTypeAccentClasses(type);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-1",
"text-[11px] font-medium leading-none",
getEventTypePillClasses(badgeType),
)}
>
<span
className={cn(
"inline-flex items-center justify-center rounded-md border px-1 py-0.5",
accent.iconChip,
)}
>
<EventTypeIcon type={type} />
</span>
<span>{EVENT_TYPE_LABEL[type]}</span>
</span>
);
});
/**
* Renders a compact avatar (image or initials).
*/
const ActorAvatar = React.memo(function ActorAvatar({
actor,
}: {
actor: ActivityActor;
}) {
const initials = useMemo(() => {
const parts = actor.name.trim().split(/\s+/).slice(0, 2);
return parts
.map((p) => (p[0] ? p[0].toUpperCase() : ""))
.join("");
}, [actor.name]);
if (actor.avatarUrl) {
return (
<img
src={actor.avatarUrl}
alt={actor.name}
className="h-9 w-9 rounded-full object-cover border border-border/60"
loading="lazy"
/>
);
}
return (
<div
className={cn(
"h-9 w-9 rounded-full border border-border/60",
"bg-muted/60 dark:bg-background/60",
"flex items-center justify-center",
"text-xs font-semibold text-foreground",
)}
aria-hidden
title={actor.name}
>
{initials || "?"}
</div>
);
});
/**
* A single feed row. Memoized to reduce re-renders when paging/filtering changes.
*/
const ActivityEventRowInner = function ActivityEventRowInner({
event,
timestampMode,
now,
}: {
event: ActivityEvent;
timestampMode: TimestampMode;
now: Date;
}) {
const timestamp = useMemo(() => {
return timestampMode === "relative"
? formatRelative(event.occurredAt, now)
: formatAbsolute(event.occurredAt);
}, [event.occurredAt, now, timestampMode]);
const accent = getEventTypeAccentClasses(event.type);
return (
<div
className={cn(
"flex gap-3 rounded-xl border border-border/60 border-l-4 bg-background/70 backdrop-blur-sm",
"px-4 py-3",
"hover:bg-muted/40 transition-colors",
accent.rowAccent,
)}
data-testid={`activity-event-${event.id}`}
>
<div className="flex items-start gap-3 min-w-0 flex-1">
<ActorAvatar actor={event.actor} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground truncate">
{event.title}
</h3>
<span className="sr-only">Event type</span>
<EventTypePill type={event.type} />
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="font-medium text-foreground/90">
{event.actor.name}
</span>
{event.actor.meta && (
<>
<span aria-hidden>·</span>
<span>{event.actor.meta}</span>
</>
)}
{event.contextLabel && (
<>
<span aria-hidden>·</span>
<span className="truncate">{event.contextLabel}</span>
</>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
{event.description}
</p>
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-1">
<time
className="text-xs text-muted-foreground whitespace-nowrap"
dateTime={event.occurredAt.toISOString()}
title={formatAbsolute(event.occurredAt)}
>
{timestamp}
</time>
</div>
</div>
);
};
/**
* Exported memoized ActivityEventRow for composable usage.
*/
export const ActivityEventRow = React.memo(ActivityEventRowInner);
// =============================================================================
// LAYOUT (COMPOSABLE) + PAGE
// =============================================================================
/**
* Root layout wrapper providing gradient background and max-width container.
* Composable building block; place custom headers and sections inside.
*/
export function ActivityFeedLayout({
children,
className,
}: ActivityFeedLayoutProps) {
return (
<div
className={cn(
"relative min-h-screen overflow-hidden",
"bg-gradient-to-br from-background via-background to-muted/40",
"text-foreground p-4 md:p-6",
className,
)}
>
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -top-32 -left-24 h-64 w-64 rounded-full bg-gradient-to-br from-primary/25 via-cyan-400/15 to-transparent blur-3xl" />
<div className="absolute -bottom-32 -right-20 h-72 w-72 rounded-full bg-gradient-to-tr from-purple-500/20 via-pink-500/10 to-transparent blur-3xl" />
</div>
<div className="relative z-10 mx-auto w-full max-w-6xl space-y-6">
{children}
</div>
</div>
);
}
/**
* Page header with title, description and search input.
* Composable; use inside ActivityFeedLayout.
*/
export function ActivityFeedHeader({
title = "Activity Feed",
description = "Track recent actions, system updates, and user activity.",
query,
onQueryChange,
}: ActivityFeedHeaderProps) {
const handleChange = useCallback(
(value: string) => {
onQueryChange(value);
},
[onQueryChange],
);
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="bg-gradient-to-r from-primary via-cyan-400 to-purple-500 bg-clip-text text-2xl font-bold tracking-tight text-transparent">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
<div className="w-full sm:w-[360px]">
<AnimatedInput
placeholder="Search activity"
variant="clean"
value={query}
onChange={handleChange}
/>
</div>
</div>
);
}
/**
* Filters section with "All" and per-type pills.
*/
export function ActivityFeedFilters({
events,
filter,
onFilterChange,
}: ActivityFeedFiltersProps) {
const sortedEvents = useMemo(
() =>
[...events].sort(
(a, b) => b.occurredAt.getTime() - a.occurredAt.getTime(),
),
[events],
);
const counts = useMemo(() => {
const map: Partial<Record<ActivityEventType, number>> = {};
for (const ev of sortedEvents) {
map[ev.type] = (map[ev.type] ?? 0) + 1;
}
return map;
}, [sortedEvents]);
const handleSelectType = useCallback(
(type: ActivityEventType | null) => {
onFilterChange({ ...filter, type });
},
[filter, onFilterChange],
);
return (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={filter.type == null ? "default" : "outline"}
onClick={() => handleSelectType(null)}
>
All{" "}
<span className="ml-2 text-xs text-muted-foreground">
{sortedEvents.length}
</span>
</Button>
{EVENT_TYPE_ORDER.map((type) => {
const count = counts[type] ?? 0;
const active = filter.type === type;
const accent = getEventTypeAccentClasses(type);
return (
<Button
key={type}
size="sm"
variant={active ? "default" : "outline"}
onClick={() => handleSelectType(type)}
disabled={count === 0}
>
<span className="inline-flex items-center gap-2">
<span
className={cn(
"inline-flex items-center justify-center rounded-md border px-1.5 py-1",
active
? "border-white/25 bg-white/15 text-primary-foreground"
: accent.iconChip,
)}
>
<EventTypeIcon type={type} />
</span>
<span>{EVENT_TYPE_LABEL[type]}</span>
<span className="text-xs text-muted-foreground">
{count}
</span>
</span>
</Button>
);
})}
</div>
);
}
/**
* Grouped list of events by date label.
*/
export function ActivityFeedList({
events,
timestampMode,
now,
}: ActivityFeedListProps) {
const grouped = useMemo(() => {
const groups: { label: string; events: ActivityEvent[] }[] = [];
for (const ev of events) {
const label = getDateGroupLabel(ev.occurredAt, now);
const last = groups[groups.length - 1];
if (last && last.label === label) {
last.events.push(ev);
} else {
groups.push({ label, events: [ev] });
}
}
return groups;
}, [events, now]);
if (events.length === 0) {
return null;
}
return (
<div className="space-y-6">
{grouped.map((group) => (
<section key={group.label} aria-label={`Events: ${group.label}`}>
<div className="sticky top-0 z-10 -mx-2 mb-3 px-2">
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-gradient-to-r from-primary/10 via-cyan-400/10 to-purple-500/10 px-3 py-1 text-xs text-muted-foreground backdrop-blur">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
group.events[0]
? getEventTypeAccentClasses(group.events[0].type).dotAccent
: "bg-primary/70",
)}
aria-hidden
/>
<span className="font-medium text-foreground/90">
{group.label}
</span>
</div>
</div>
<div className="space-y-3">
{group.events.map((event) => (
<ActivityEventRow
key={event.id}
event={event}
now={now}
timestampMode={timestampMode}
/>
))}
</div>
</section>
))}
</div>
);
}
/**
* Pagination / infinite controls, composable.
*/
export function ActivityFeedPagination({
pagingMode,
currentPage,
totalPages,
canLoadMore,
onPageChange,
onLoadMore,
}: ActivityFeedPaginationProps) {
const [isNarrowViewport, setIsNarrowViewport] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Compact pagination on small screens.
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 480px)");
const update = () => setIsNarrowViewport(mediaQuery.matches);
update();
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}, []);
// Intersection observer for infinite mode.
useEffect(() => {
if (pagingMode !== "infinite") return;
if (!canLoadMore) return;
const node = sentinelRef.current;
if (!node) return;
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) return;
if (entry.isIntersecting) {
onLoadMore();
}
},
{ root: null, rootMargin: "200px", threshold: 0 },
);
obs.observe(node);
return () => obs.disconnect();
}, [pagingMode, canLoadMore, onLoadMore]);
if (pagingMode === "pagination") {
return (
<div className="w-full overflow-x-auto">
<div className="min-w-max">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
siblingCount={isNarrowViewport ? 0 : 1}
/>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center gap-3 pt-2">
{canLoadMore ? (
<Button variant="outline" size="md" onClick={onLoadMore}>
Load more
</Button>
) : (
<p className="text-xs text-muted-foreground">
You’ve reached the end of the feed.
</p>
)}
<div ref={sentinelRef} aria-hidden className="h-1 w-full" />
</div>
);
}
// =============================================================================
// PAGE (PRE-COMPOSED)
// =============================================================================
/**
* Activity Feed Page (pre-composed).
*/
export function ActivityFeedPage({
events,
title = "Activity Feed",
description = "Track recent actions, system updates, and user activity.",
timestampMode = "relative",
pagingMode = "pagination",
pageSize = DEFAULT_PAGE_SIZE,
filterState,
onFilterChange,
className,
}: ActivityFeedPageProps) {
const [internalFilter, setInternalFilter] = useState<ActivityFeedFilterState>({
type: null,
query: "",
});
const effectiveFilter = filterState ?? internalFilter;
const [page, setPage] = useState(1);
const [infiniteCount, setInfiniteCount] = useState(pageSize);
const [now, setNow] = useState<Date>(() => new Date());
// Refresh "relative" timestamps periodically for realism in Storybook.
useEffect(() => {
if (timestampMode !== "relative") return;
const interval = window.setInterval(() => {
setNow(new Date());
}, 30_000);
return () => window.clearInterval(interval);
}, [timestampMode]);
const sortedEvents = useMemo(() => {
return [...events].sort(
(a, b) => b.occurredAt.getTime() - a.occurredAt.getTime(),
);
}, [events]);
const filtered = useMemo(() => {
const type = effectiveFilter.type;
return sortedEvents.filter((ev) => {
if (type && ev.type !== type) return false;
return matchesQuery(ev, effectiveFilter.query);
});
}, [sortedEvents, effectiveFilter.type, effectiveFilter.query]);
const totalPages = useMemo(() => {
if (pagingMode !== "pagination") return 1;
return Math.max(1, Math.ceil(filtered.length / pageSize));
}, [filtered.length, pageSize, pagingMode]);
const pageEvents = useMemo(() => {
if (pagingMode === "pagination") {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}
return filtered.slice(0, infiniteCount);
}, [filtered, pagingMode, page, pageSize, infiniteCount]);
const setFilter = useCallback(
(next: ActivityFeedFilterState) => {
// Reset paging whenever the filter/search criteria changes
// so the user always lands on the first page of the new results.
setPage(1);
setInfiniteCount(pageSize);
if (!filterState) setInternalFilter(next);
onFilterChange?.(next);
},
[filterState, onFilterChange, pageSize],
);
const handleSearchChange = useCallback(
(query: string) => {
setFilter({ ...effectiveFilter, query });
},
[effectiveFilter, pageSize, setFilter],
);
const handlePageChange = useCallback((nextPage: number) => {
setPage(nextPage);
}, []);
const canLoadMore = pagingMode === "infinite" && infiniteCount < filtered.length;
const handleLoadMore = useCallback(() => {
setInfiniteCount((prev) => Math.min(filtered.length, prev + pageSize));
}, [filtered.length, pageSize]);
return (
<ActivityFeedLayout className={className}>
<div className="space-y-6">
<ActivityFeedHeader
title={title}
description={description}
query={effectiveFilter.query}
onQueryChange={handleSearchChange}
/>
<Card variant="default" className="border border-border/60 shadow-sm">
<CardHeader variant="compact" className="gap-3">
<div className="flex flex-col gap-1">
<CardTitle size="md">Events</CardTitle>
<CardDescription>
Newest first · Filter by type · Search across titles, actors, and descriptions
</CardDescription>
</div>
<ActivityFeedFilters
events={sortedEvents}
filter={effectiveFilter}
onFilterChange={setFilter}
/>
</CardHeader>
<CardContent variant="compact" className="space-y-5">
{filtered.length === 0 ? (
<div className="rounded-xl border border-border/60 bg-muted/20 p-10 text-center">
<p className="text-sm font-medium text-foreground">
No events found
</p>
<p className="mt-1 text-sm text-muted-foreground">
Try a different filter or search query.
</p>
</div>
) : (
<>
<ActivityFeedList
events={pageEvents}
timestampMode={timestampMode}
now={now}
/>
<ActivityFeedPagination
pagingMode={pagingMode}
currentPage={page}
totalPages={totalPages}
canLoadMore={canLoadMore}
onPageChange={handlePageChange}
onLoadMore={handleLoadMore}
/>
</>
)}
</CardContent>
</Card>
</div>
</ActivityFeedLayout>
);
}
export default ActivityFeedPage;
Features
- Chronological event list: events are always sorted newest-first by their
occurredAttimestamp. - Event type badges: each event shows an icon and badge (auth, user, security, billing, etc.) with theme-aware colors.
- Actor + context: display who performed the action plus extra context like invoice or order IDs.
- Relative or absolute time: show friendly
"2 hours ago"strings or full absolute timestamps. - Search + filters: filter by event type and search across titles, descriptions, actors, and context.
- Pagination or infinite scroll: choose between numbered pages or a "Load more" / infinite pattern.
- Composable sections:
ActivityFeedLayout,ActivityFeedHeader,ActivityFeedFilters,ActivityFeedList,ActivityEventRow, andActivityFeedPaginationcan be used independently.
Props
Page props (ActivityFeedPage)
type ActivityEventType =
| "authentication"
| "user"
| "security"
| "system"
| "billing"
| "order"
| "comment"
| "document"
| "schedule"
| "warning";
interface ActivityActor {
name: string;
meta?: string;
avatarUrl?: string;
}
interface ActivityEvent {
id: string;
type: ActivityEventType;
actor: ActivityActor;
occurredAt: Date;
title: string;
description: string;
contextLabel?: string;
}
type TimestampMode = "relative" | "absolute";
type FeedPagingMode = "pagination" | "infinite";
interface ActivityFeedFilterState {
type: ActivityEventType | null;
query: string;
}
| Prop | Type | Description |
|---|---|---|
events | ActivityEvent[] | Events to display in the feed; they are sorted newest-first by occurredAt. |
title | string (optional) | Page title; defaults to "Activity Feed". |
description | string (optional) | Subtitle shown under the title. |
timestampMode | "relative" | "absolute" (optional) | Controls whether timestamps are rendered as "2 hours ago" or as absolute locale strings. Defaults to "relative". |
pagingMode | "pagination" | "infinite" (optional) | Whether to paginate with numbered pages or use an infinite "Load more" pattern. Defaults to "pagination". |
pageSize | number (optional) | Number of events per page or per load. Defaults to 10. |
filterState | ActivityFeedFilterState (optional) | Controlled filter state (type + search query). When omitted, the page manages its own filter state. |
onFilterChange | (next: ActivityFeedFilterState) => void (optional) | Called when the user changes the type filter or search query. Use this to control filterState from above. |
className | string (optional) | Additional class names for the outer layout wrapper. |
Composable component props
ActivityFeedLayout
interface ActivityFeedLayoutProps {
children: React.ReactNode;
className?: string;
}
| Prop | Type | Description |
|---|---|---|
children | ReactNode | Page content (header, filters, list, etc.). |
className | string (optional) | Extra Tailwind classes for the outer layout container. |
ActivityFeedHeader
interface ActivityFeedHeaderProps {
title?: string;
description?: string;
query: string;
onQueryChange: (value: string) => void;
}
| Prop | Type | Description |
|---|---|---|
title | string (optional) | Page title; defaults to "Activity Feed". |
description | string (optional) | Short subtitle below the title. |
query | string | Current search query value. |
onQueryChange | (value: string) => void | Called when the user types into the search input. |
ActivityFeedFilters
interface ActivityFeedFiltersProps {
events: ActivityEvent[];
filter: ActivityFeedFilterState;
onFilterChange: (next: ActivityFeedFilterState) => void;
}
| Prop | Type | Description |
|---|---|---|
events | ActivityEvent[] | Full events list used to compute per-type counts. |
filter | ActivityFeedFilterState | Current filter state (type + query). |
onFilterChange | (next: ActivityFeedFilterState) => void | Called when the user clicks a type chip; query is preserved. |
ActivityFeedList
interface ActivityFeedListProps {
events: ActivityEvent[];
timestampMode: TimestampMode;
now: Date;
}
| Prop | Type | Description |
|---|---|---|
events | ActivityEvent[] | Already-filtered and paged events; the list groups them by date label (Today, Yesterday, etc.). |
timestampMode | "relative" | "absolute" | Controls timestamp rendering for each row. |
now | Date | Reference timestamp used for computing relative times. |
ActivityEventRow
interface ActivityEventRowProps {
event: ActivityEvent;
timestampMode: TimestampMode;
now: Date;
}
| Prop | Type | Description |
|---|---|---|
event | ActivityEvent | The event to render. |
timestampMode | "relative" | "absolute" | Controls how the row’s timestamp is rendered. |
now | Date | Reference timestamp for relative calculations. |
ActivityFeedPagination
interface ActivityFeedPaginationProps {
pagingMode: FeedPagingMode;
currentPage: number;
totalPages: number;
canLoadMore: boolean;
onPageChange: (page: number) => void;
onLoadMore: () => void;
}
| Prop | Type | Description |
|---|---|---|
pagingMode | "pagination" | "infinite" | Determines whether a Pagination control or "Load more" pattern is rendered. |
currentPage | number | Current page index (1-based) for pagination mode. |
totalPages | number | Total page count for pagination mode. |
canLoadMore | boolean | Whether there are more events to load in infinite mode. |
onPageChange | (page: number) => void | Called when the user selects a different page. |
onLoadMore | () => void | Called when the user clicks "Load more" or when the sentinel enters view (infinite mode). |
Usage Examples
Basic activity feed
<ActivityFeedPage
events={events}
/>
With controlled filter state
const [filter, setFilter] = useState<ActivityFeedFilterState>({
type: null,
query: "",
});
<ActivityFeedPage
events={events}
filterState={filter}
onFilterChange={setFilter}
pagingMode="pagination"
pageSize={10}
/>
Infinite "Load more" feed
<ActivityFeedPage
events={events}
pagingMode="infinite"
pageSize={20}
/>
Composable layout using building blocks
import React, { useCallback, useMemo, useState } from "react";
import {
ActivityFeedLayout,
ActivityFeedHeader,
ActivityFeedFilters,
ActivityFeedList,
ActivityFeedPagination,
type ActivityEvent,
type ActivityFeedFilterState,
} from "@ignix-ui/activity-feed-page";
function ComposableActivityFeed({ events }: { events: ActivityEvent[] }) {
const [filter, setFilter] = useState<ActivityFeedFilterState>({
type: null,
query: "",
});
const [page, setPage] = useState(1);
const pageSize = 10;
const sorted = useMemo(
() => [...events].sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime()),
[events],
);
const filtered = useMemo(
() =>
sorted.filter((event) => {
if (filter.type && event.type !== filter.type) return false;
const q = filter.query.trim().toLowerCase();
if (!q) return true;
const text = [
event.title,
event.description,
event.actor.name,
event.actor.meta ?? "",
event.contextLabel ?? "",
]
.join(" ")
.toLowerCase();
return text.includes(q);
}),
[sorted, filter.type, filter.query],
);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const pagedEvents = useMemo(() => {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page, pageSize]);
const handleFilterChange = useCallback((next: ActivityFeedFilterState) => {
setFilter(next);
setPage(1);
}, []);
const handleQueryChange = useCallback((query: string) => {
setFilter((prev) => ({ ...prev, query }));
setPage(1);
}, []);
return (
<ActivityFeedLayout>
<ActivityFeedHeader
title="Activity Feed"
description="Track recent actions, system updates, and user activity."
query={filter.query}
onQueryChange={handleQueryChange}
/>
<ActivityFeedFilters
events={sorted}
filter={filter}
onFilterChange={handleFilterChange}
/>
<ActivityFeedList
events={pagedEvents}
timestampMode="relative"
now={new Date()}
/>
<ActivityFeedPagination
pagingMode="pagination"
currentPage={page}
totalPages={totalPages}
canLoadMore={false}
onPageChange={setPage}
onLoadMore={() => undefined}
/>
</ActivityFeedLayout>
);
}