Activity Log
Paginated activity feed for audit history and analytics changes. Install Activity Log from the VLLNT UI registry with the shadcn CLI.
Paginated activity feed for audit history and analytics changes. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Activity Log to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/activity-log.jsonSource
"use client";
import { useMemo, useState } from "react";
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../../lib/utils";
import { Avatar, AvatarFallback } from "../avatar/avatar";
import { Badge } from "../badge/badge";
import { Button } from "../button/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../card/card";
import { ScrollArea } from "../scroll-area/scroll-area";
import { Separator } from "../separator/separator";
export type ActivityLogTone = "danger" | "default" | "success" | "warning";
export type ActivityLogItem = {
action: string;
actor: string;
description?: string;
id: string;
scope?: string;
target?: string;
timestamp: string;
tone?: ActivityLogTone;
};
export type ActivityLogProps = React.ComponentPropsWithoutRef<typeof Card> & {
defaultPage?: number;
description?: string;
emptyMessage?: string;
items: ActivityLogItem[];
onPageChange?: (page: number) => void;
page?: number;
pageSize?: number;
title?: string;
};
type ActivityToneConfig = {
badgeClassName: string;
markerClassName: string;
};
type ActivityRowProps = {
item: ActivityLogItem;
};
type PaginationControlsProps = {
currentPage: number;
onPageChange: (page: number) => void;
pageNumbers: number[];
totalPages: number;
};
const toneConfig: Record<ActivityLogTone, ActivityToneConfig> = {
danger: {
badgeClassName:
"border-destructive/20 bg-destructive/10 text-destructive dark:text-destructive",
markerClassName: "bg-destructive",
},
default: {
badgeClassName: "border-border bg-muted text-muted-foreground",
markerClassName: "bg-primary",
},
success: {
badgeClassName:
"border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
markerClassName: "bg-emerald-500",
},
warning: {
badgeClassName:
"border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300",
markerClassName: "bg-amber-500",
},
};
function getInitials(name: string): string {
return name
.split(" ")
.map((segment) => segment[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function buildPageNumbers(currentPage: number, totalPages: number): number[] {
if (totalPages <= 1) return [1];
const start = Math.max(1, currentPage - 1);
const end = Math.min(totalPages, start + 2);
const normalizedStart = Math.max(1, end - 2);
return Array.from(
{ length: end - normalizedStart + 1 },
(_, index) => normalizedStart + index,
);
}
function ActivityLogHeader({
currentPage,
description,
title,
totalPages,
}: {
currentPage: number;
description?: string;
title: string;
totalPages: number;
}) {
return (
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>{title}</CardTitle>
{description ? (
<CardDescription>{description}</CardDescription>
) : null}
</div>
<Badge className="w-fit" variant="outline">
Page {currentPage} of {totalPages}
</Badge>
</div>
</CardHeader>
);
}
function PaginationControls({
currentPage,
onPageChange,
pageNumbers,
totalPages,
}: PaginationControlsProps) {
return (
<div className="flex flex-wrap items-center gap-2">
<Button
disabled={currentPage === 1}
onClick={() => {
onPageChange(currentPage - 1);
}}
size="sm"
variant="outline"
>
<ChevronLeft className="size-4" />
Previous
</Button>
{pageNumbers.map((pageNumber) => (
<Button
aria-label={`Go to page ${pageNumber}`}
key={pageNumber}
onClick={() => {
onPageChange(pageNumber);
}}
size="sm"
variant={pageNumber === currentPage ? "default" : "outline"}
>
{pageNumber}
</Button>
))}
<Button
disabled={currentPage === totalPages}
onClick={() => {
onPageChange(currentPage + 1);
}}
size="sm"
variant="outline"
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
);
}
function ActivityRow({ item }: ActivityRowProps) {
const palette = toneConfig[item.tone ?? "default"];
return (
<li className="relative pl-12">
<span
aria-hidden="true"
className="absolute bottom-[-1.5rem] left-[18px] top-11 w-px bg-border last:hidden"
/>
<span
aria-hidden="true"
className={cn(
"absolute left-4 top-3 size-3 rounded-full ring-4 ring-background",
palette.markerClassName,
)}
/>
<div className="rounded-lg border bg-background/70 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="size-9 border bg-muted">
<AvatarFallback>{getInitials(item.actor)}</AvatarFallback>
</Avatar>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">
{item.actor}
</span>
<ArrowRight className="size-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{item.action}
</span>
{item.target ? (
<span className="truncate text-sm font-medium text-foreground">
{item.target}
</span>
) : null}
</div>
{item.description ? (
<p className="text-sm text-muted-foreground">
{item.description}
</p>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
{item.scope ? (
<Badge className={palette.badgeClassName} variant="outline">
{item.scope}
</Badge>
) : null}
<span className="text-xs text-muted-foreground">
{item.timestamp}
</span>
</div>
</div>
</div>
</li>
);
}
function ActivityLogBody({
currentPage,
emptyMessage,
items,
onPageChange,
pageNumbers,
pageSize,
totalPages,
}: {
currentPage: number;
emptyMessage: string;
items: ActivityLogItem[];
onPageChange: (page: number) => void;
pageNumbers: number[];
pageSize: number;
totalPages: number;
}) {
const visibleItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [currentPage, items, pageSize]);
if (items.length === 0) {
return (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
);
}
return (
<>
<ScrollArea className="max-h-[26rem] pr-4">
<ol className="space-y-4 pb-2">
{visibleItems.map((item) => (
<ActivityRow item={item} key={item.id} />
))}
</ol>
</ScrollArea>
<Separator />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(currentPage - 1) * pageSize + 1}
{" - "}
{(currentPage - 1) * pageSize + visibleItems.length} of {items.length}
</p>
<PaginationControls
currentPage={currentPage}
onPageChange={onPageChange}
pageNumbers={pageNumbers}
totalPages={totalPages}
/>
</div>
</>
);
}
const ActivityLog = ({
className,
defaultPage = 1,
description,
emptyMessage = "No activity recorded yet.",
items,
onPageChange,
page,
pageSize = 5,
ref,
title = "Activity log",
...props
}: ActivityLogProps & { ref?: React.Ref<HTMLDivElement> }) => {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [uncontrolledPage, setUncontrolledPage] = useState(defaultPage);
const currentPage = Math.min(
Math.max(page ?? uncontrolledPage, 1),
totalPages,
);
const pageNumbers = useMemo(
() => buildPageNumbers(currentPage, totalPages),
[currentPage, totalPages],
);
function handlePageChange(nextPage: number) {
if (page === undefined) {
setUncontrolledPage(nextPage);
}
onPageChange?.(nextPage);
}
return (
<Card className={cn("w-full", className)} ref={ref} {...props}>
<ActivityLogHeader
currentPage={currentPage}
description={description}
title={title}
totalPages={totalPages}
/>
<CardContent className="space-y-4">
<ActivityLogBody
currentPage={currentPage}
emptyMessage={emptyMessage}
items={items}
onPageChange={handlePageChange}
pageNumbers={pageNumbers}
pageSize={pageSize}
totalPages={totalPages}
/>
</CardContent>
</Card>
);
};
ActivityLog.displayName = "ActivityLog";
export { ActivityLog };
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.