Live Feed

Rolling activity stream for surfacing incidents, deploys, and operational signals in real time.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/live-feed.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import * as React from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../card"; import { SeverityBadge, type SeverityBadgeLevel, } from "../severity-badge/severity-badge"; export type LiveFeedEvent = { id: string; message?: string; severity: SeverityBadgeLevel; source?: string; timestamp: Date | number | string; title: string; }; export type LiveFeedProps = React.ComponentPropsWithoutRef<"div"> & { description?: string; emptyLabel?: string; events: LiveFeedEvent[]; maxItems?: number; now?: Date | number | string; tickMs?: number; title?: string; }; const SECOND_MS = 1000; const MINUTE_MS = 60 * SECOND_MS; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; function normalizeDate(input: Date | number | string): Date { if (input instanceof Date) { return new Date(input.getTime()); } return new Date(input); } function useLiveDate(now: LiveFeedProps["now"], tickMs: number) { const fixedNow = React.useMemo( () => (now ? normalizeDate(now) : undefined), [now], ); const [liveNow, setLiveNow] = React.useState<Date>(fixedNow ?? new Date()); React.useEffect(() => { if (fixedNow) { setLiveNow(fixedNow); return; } const interval = window.setInterval(() => { setLiveNow(new Date()); }, tickMs); return () => { window.clearInterval(interval); }; }, [fixedNow, tickMs]); return liveNow; } function formatRelative(eventDate: Date, now: Date): string { const deltaMs = now.getTime() - eventDate.getTime(); if (deltaMs < 5 * SECOND_MS) { return "just now"; } if (deltaMs < MINUTE_MS) { return `${Math.floor(deltaMs / SECOND_MS)}s ago`; } if (deltaMs < HOUR_MS) { return `${Math.floor(deltaMs / MINUTE_MS)}m ago`; } if (deltaMs < DAY_MS) { return `${Math.floor(deltaMs / HOUR_MS)}h ago`; } if (deltaMs < 7 * DAY_MS) { return `${Math.floor(deltaMs / DAY_MS)}d ago`; } return SHORT_DATE_FORMATTER.format(eventDate); } const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", }); const ABSOLUTE_FORMATTER = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", month: "short", second: "2-digit", }); function formatAbsolute(eventDate: Date): string { return ABSOLUTE_FORMATTER.format(eventDate); } function sortEventsDesc(events: LiveFeedEvent[]): LiveFeedEvent[] { return [...events].sort( (a, b) => normalizeDate(b.timestamp).getTime() - normalizeDate(a.timestamp).getTime(), ); } function LiveFeedRow({ event, isLatest, now, }: { event: LiveFeedEvent; isLatest: boolean; now: Date; }) { const eventDate = normalizeDate(event.timestamp); return ( <li className="flex gap-3 border-b border-border/60 py-3 last:border-b-0"> <div className="pt-1"> <SeverityBadge level={event.severity} pulse={isLatest ? event.severity === "critical" : undefined} tone="soft" /> </div> <div className="min-w-0 flex-1"> <div className="flex items-baseline justify-between gap-2"> <p className="truncate text-sm font-medium">{event.title}</p> <time className="whitespace-nowrap text-xs text-muted-foreground" dateTime={eventDate.toISOString()} title={formatAbsolute(eventDate)} > {formatRelative(eventDate, now)} </time> </div> {event.message ? ( <p className="mt-0.5 text-sm text-muted-foreground"> {event.message} </p> ) : null} {event.source ? ( <p className="mt-1 text-xs uppercase tracking-[0.14em] text-muted-foreground"> {event.source} </p> ) : null} </div> </li> ); } export const LiveFeed = React.forwardRef<HTMLDivElement, LiveFeedProps>( ( { className, description, emptyLabel = "No events yet", events, maxItems = 50, now, tickMs = 30_000, title = "Live feed", ...props }, ref, ) => { const liveNow = useLiveDate(now, tickMs); const visibleEvents = React.useMemo( () => sortEventsDesc(events).slice(0, maxItems), [events, maxItems], ); return ( <Card className={cn("shadow-sm", className)} ref={ref} {...props}> <CardHeader className="flex flex-row items-start justify-between gap-3 gap-y-0 pb-3"> <div className="space-y-1"> <CardTitle className="text-base">{title}</CardTitle> {description ? ( <CardDescription>{description}</CardDescription> ) : null} </div> <Badge variant="outline"> <span aria-hidden="true" className="relative mr-1.5 inline-flex size-2" > <span className="absolute inline-flex size-2 animate-ping rounded-full bg-emerald-500 opacity-70" /> <span className="relative inline-flex size-2 rounded-full bg-emerald-500" /> </span>{" "} Live </Badge> </CardHeader> <CardContent className="pt-0"> {visibleEvents.length === 0 ? ( <div className="py-8 text-center text-sm text-muted-foreground"> {emptyLabel} </div> ) : ( <ul className="max-h-[360px] divide-y divide-border/60 overflow-y-auto"> {visibleEvents.map((event, index) => ( <LiveFeedRow event={event} isLatest={index === 0} key={event.id} now={liveNow} /> ))} </ul> )} </CardContent> </Card> ); }, ); LiveFeed.displayName = "LiveFeed";

Dependencies

  • @vllnt/ui@^0.2.1