Activity Log

Paginated activity feed for audit history and analytics changes. Install Activity Log from the VLLNT UI registry with the shadcn CLI.

Report a bug

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.json

Source

"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.

Dependencies

  • @vllnt/ui@^0.3.0