A live-tailing log viewer with severity-tinted rows, filter chips, and pausing on hover or when scrolled offscreen.
npx shadcn@latest add @paragon/log-stream"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogLine {
level: LogLevel;
service: string;
message: string;
}
export interface LogStreamProps extends React.ComponentProps<"div"> {
/** Pool of lines the stream cycles through deterministically. */
lines?: LogLine[];
/** ms between appended lines. */
interval?: number;
/** Max lines kept in the viewport buffer. */
bufferSize?: number;
/** Disables the streaming ticker (renders a static buffer). */
static?: boolean;
}
const LEVEL_STYLE: Record<
LogLevel,
{ text: string; tint: string; label: string; dot: string }
> = {
debug: {
text: "text-muted-foreground",
tint: "transparent",
label: "DBG",
dot: "var(--color-muted-foreground)",
},
info: {
text: "text-foreground",
tint: "transparent",
label: "INF",
dot: "var(--color-success)",
},
warn: {
text: "text-warning",
tint: "color-mix(in oklch, var(--color-warning) 8%, transparent)",
label: "WRN",
dot: "var(--color-warning)",
},
error: {
text: "text-destructive",
tint: "color-mix(in oklch, var(--color-destructive) 8%, transparent)",
label: "ERR",
dot: "var(--color-destructive)",
},
};
const DEFAULT_LINES: LogLine[] = [
{ level: "info", service: "api", message: "GET /v1/orders 200 in 42ms" },
{ level: "info", service: "auth", message: "issued token for user_8823" },
{ level: "debug", service: "worker", message: "job queue depth=3" },
{ level: "warn", service: "redis", message: "connection pool at 82% capacity" },
{ level: "info", service: "api", message: "POST /v1/checkout 201 in 118ms" },
{ level: "error", service: "billing", message: "stripe webhook 503 — retrying (2/5)" },
{ level: "info", service: "cdn", message: "cache HIT edge-fra1 /assets/app.js" },
{ level: "debug", service: "scheduler", message: "next cron in 240s" },
{ level: "warn", service: "orders", message: "p95 latency 610ms exceeds SLO" },
{ level: "info", service: "auth", message: "GET /v1/session 200 in 11ms" },
];
const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
interface Row {
id: number;
line: LogLine;
ts: string;
}
export function LogStream({
lines = DEFAULT_LINES,
interval = 1100,
bufferSize = 9,
static: isStatic = false,
className,
...props
}: LogStreamProps) {
const reduced = useReducedMotion();
// Deterministic initial buffer — id derived from a counter, never Date.now.
const build = React.useCallback(
(count: number): Row[] =>
lines.slice(0, Math.min(count, lines.length)).map((line, i) => ({
id: i,
line,
ts: fmtTime(i),
})),
[lines],
);
const [buffer, setBuffer] = React.useState<Row[]>(() =>
build(Math.min(5, bufferSize)),
);
const [filters, setFilters] = React.useState<Set<LogLevel>>(new Set());
const [hovered, setHovered] = React.useState(false);
const [inView, setInView] = React.useState(true);
const cursor = React.useRef(Math.min(5, bufferSize));
const ref = React.useRef<HTMLDivElement>(null);
// Reset the stream when the source pool changes (scenario switch / replay).
React.useEffect(() => {
cursor.current = Math.min(5, bufferSize);
setBuffer(build(Math.min(5, bufferSize)));
}, [build, bufferSize]);
const paused = hovered || !inView || isStatic;
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), {
threshold: 0.1,
});
io.observe(el);
return () => io.disconnect();
}, []);
React.useEffect(() => {
if (paused) return;
const t = setInterval(() => {
setBuffer((prev) => {
const c = cursor.current;
const next = lines[c % lines.length];
cursor.current = c + 1;
return [...prev, { id: c, line: next, ts: fmtTime(c) }].slice(
-bufferSize,
);
});
}, interval);
return () => clearInterval(t);
}, [paused, interval, lines, bufferSize]);
const visible = buffer.filter(
(b) => filters.size === 0 || filters.has(b.line.level),
);
const toggle = (lvl: LogLevel) =>
setFilters((prev) => {
const next = new Set(prev);
if (next.has(lvl)) next.delete(lvl);
else next.add(lvl);
return next;
});
const animate = !reduced && !isStatic;
return (
<div
ref={ref}
className={cn(
"flex w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span
aria-hidden
className="size-2 rounded-full transition-colors duration-(--duration-quick) ease-out"
style={{
background: paused
? "var(--color-muted-foreground)"
: "var(--color-success)",
}}
/>
<span className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
{paused ? <Pause className="size-3" /> : <Play className="size-3" />}
{paused ? "Paused" : "Tailing"}
</span>
<div className="ml-auto flex gap-1">
{LEVELS.map((lvl) => {
const on = filters.has(lvl);
return (
<button
key={lvl}
type="button"
onClick={() => toggle(lvl)}
aria-pressed={on}
aria-label={`Filter ${lvl}`}
className={cn(
"pressable inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase outline-none transition-[background-color,color,opacity] duration-(--duration-quick) ease-out focus-visible:ring-2 focus-visible:ring-ring",
on
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-secondary/60",
filters.size > 0 && !on && "opacity-50",
)}
>
<span
className="size-1.5 rounded-full"
style={{ background: LEVEL_STYLE[lvl].dot }}
aria-hidden
/>
{LEVEL_STYLE[lvl].label}
</button>
);
})}
</div>
</div>
<div
className="h-[240px] overflow-hidden font-mono text-xs"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{visible.length === 0 ? (
<div className="flex h-full items-center justify-center text-muted-foreground">
No lines match the active filters.
</div>
) : (
<div className="flex h-full flex-col justify-end">
<AnimatePresence initial={false}>
{visible.map((b) => {
const s = LEVEL_STYLE[b.line.level];
return (
<motion.div
key={b.id}
layout={animate ? "position" : false}
initial={
animate
? { opacity: 0, y: 8, filter: "blur(4px)" }
: false
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="flex items-baseline gap-2 px-3 py-1"
style={{ background: s.tint }}
>
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{b.ts}
</span>
<span className={cn("w-7 shrink-0 font-semibold", s.text)}>
{s.label}
</span>
<span className="shrink-0 text-muted-foreground">
{b.line.service}
</span>
<span className={cn("truncate", s.text)}>
{b.line.message}
</span>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</div>
</div>
);
}
function fmtTime(n: number) {
// Deterministic HH:MM:SS derived from a counter — no Date.now().
const base = 8 * 3600 + 41 * 60 + 3; // 08:41:03
const t = base + n * 3;
const hh = Math.floor(t / 3600) % 24;
const mm = Math.floor((t % 3600) / 60);
const ss = t % 60;
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
function pad(n: number) {
return n.toString().padStart(2, "0");
}