A live log viewer with level filters and counts, search with highlighted matches, wrap, pause with a new-lines counter, follow-the-tail scrolling and copy.
Deployment logs
Waiting for output…
"use client"
import * as React from "react"
import { LogStream, type LogLine } from "@/components/ballmac/log-stream"
const script: Omit<LogLine, "id" | "time">[] = [
{ level: "info", source: "build", message: "Cloning repository acme/storefront (branch main)" },
{ level: "info", source: "build", message: "Installing dependencies with pnpm 10.4" },
{ level: "debug", source: "build", message: "Restored 1,284 packages from cache in 1.2s" },
{ level: "info", source: "build", message: "Running next build" },
{ level: "warn", source: "build", message: "Image /hero.png is 2.1 MB; consider compressing it" },
{ level: "info", source: "build", message: "Compiled successfully in 14.8s" },
{ level: "error", source: "deploy", message: "Health check failed on /api/health: 503 Service Unavailable" },
{ level: "info", source: "deploy", message: "Retrying health check (2 of 5)" },
{ level: "info", source: "deploy", message: "Health check passed in 412 ms" },
{ level: "info", source: "deploy", message: "Promoted deployment dpl_4f9a to production" },
]
export default function LogStreamDemo() {
const [lines, setLines] = React.useState<LogLine[]>([])
React.useEffect(() => {
let i = 0
const base = Date.UTC(2026, 8, 30, 14, 2, 11)
const id = setInterval(() => {
const next = script[i % script.length]!
const n = i
setLines((all) => [...all, { ...next, id: n, time: base + n * 730 }].slice(-300))
i++
}, 650)
return () => clearInterval(id)
}, [])
return (
<div className="w-full max-w-2xl">
<LogStream title="Deployment logs" lines={lines} height="18rem" onClear={() => setLines([])} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/log-streamInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/copy-button @ballmac/i18nCopy the source into your project.
components/ballmac/log-stream.tsx// Ballmac UI: Log Stream. https://ui.ballmac.com/components/log-stream "use client" import * as React from "react" import { ArrowDown, Pause, Play, Search, WrapText } from "lucide-react" import { CopyButton } from "@/components/ballmac/copy-button" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type LogLevel = "debug" | "info" | "warn" | "error" type LogLine = { /** Unique, stable id for the line. */ id: string | number /** When it happened: an ISO string or epoch milliseconds. Shown as HH:MM:SS.mmm in UTC. */ time: string | number /** Severity. */ level: LogLevel /** The message. */ message: string /** Where it came from, such as "api" or "worker-2". */ source?: string } const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"] const LEVEL_META: Record<LogLevel, { label: string; bar: string; chip: string }> = { debug: { label: "DEBUG", bar: "bg-muted-foreground/40", chip: "bg-muted" }, info: { label: "INFO", bar: "bg-chart-1", chip: "bg-chart-1/10" }, warn: { label: "WARN", bar: "bg-chart-3", chip: "bg-chart-3/15" }, error: { label: "ERROR", bar: "bg-destructive", chip: "bg-destructive/10" }, } function clock(time: string | number) { const d = new Date(time) if (Number.isNaN(d.getTime())) return "--:--:--.---" const p = (n: number, w = 2) => String(n).padStart(w, "0") return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}` } function isoOf(time: string | number) { const d = new Date(time) return Number.isNaN(d.getTime()) ? undefined : d.toISOString() } function Highlight({ text, query }: { text: string; query: string }) { if (!query) return <>{text}</> const parts: React.ReactNode[] = [] const lower = text.toLowerCase() const q = query.toLowerCase() let from = 0 for (let at = lower.indexOf(q); at !== -1; at = lower.indexOf(q, from)) { if (at > from) parts.push(text.slice(from, at)) parts.push( <mark key={at} className="rounded-[3px] bg-chart-3/40 px-px text-foreground"> {text.slice(at, at + q.length)} </mark> ) from = at + q.length } parts.push(text.slice(from)) return <>{parts}</> } type LogStreamProps = Omit<React.ComponentProps<"div">, "title"> & { /** All lines so far, oldest first. Append to this array as the stream delivers. */ lines: LogLine[] /** Heading in the toolbar. */ title?: string /** Most lines kept on screen; older ones are dropped from view. */ maxLines?: number /** Stick to the newest line while at the bottom. */ follow?: boolean /** Height of the log area. */ height?: string /** Wrap long messages instead of scrolling sideways. */ wrap?: boolean /** Hide the source column. */ hideSource?: boolean /** Adds a Clear button that calls this. */ onClear?: () => void /** Shown when there are no lines yet. */ emptyText?: string } function LogStream({ lines, title, maxLines = 1000, follow = true, height = "20rem", wrap: wrapProp = false, hideSource = false, onClear, emptyText, className, ...props }: LogStreamProps) { const msg = useMessages() title ??= msg("log-stream.title", "Logs") emptyText ??= msg("log-stream.emptyText", "Waiting for output…") const [active, setActive] = React.useState<Set<LogLevel>>(new Set(LEVELS)) const [query, setQuery] = React.useState("") const [wrap, setWrap] = React.useState(wrapProp) const [paused, setPaused] = React.useState(false) const [pausedAt, setPausedAt] = React.useState(0) const [atBottom, setAtBottom] = React.useState(true) const scroller = React.useRef<HTMLDivElement>(null) const source = paused ? lines.slice(0, pausedAt) : lines const q = query.trim() const visible = source .filter((l) => active.has(l.level) && (!q || l.message.toLowerCase().includes(q.toLowerCase()) || l.source?.toLowerCase().includes(q.toLowerCase()))) .slice(-maxLines) const counts = LEVELS.reduce((acc, level) => ({ ...acc, [level]: source.filter((l) => l.level === level).length }), {} as Record<LogLevel, number>) const hiddenNew = paused ? lines.length - pausedAt : 0 const newestId = visible[visible.length - 1]?.id React.useLayoutEffect(() => { const el = scroller.current if (el && follow && !paused && atBottom) el.scrollTop = el.scrollHeight }, [newestId, visible.length, follow, paused, atBottom]) function onScroll() { const el = scroller.current if (!el) return setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 24) } function jump() { const el = scroller.current if (el) el.scrollTop = el.scrollHeight setAtBottom(true) if (paused) setPaused(false) } const copyText = () => visible.map((l) => `${isoOf(l.time) ?? l.time} ${l.level.toUpperCase()}${l.source ? ` [${l.source}]` : ""} ${l.message}`).join("\n") return ( <div data-slot="log-stream" dir="ltr" className={cn("@container flex w-full flex-col overflow-hidden rounded-xl border bg-card text-card-foreground", className)} {...props} > <div className="flex flex-wrap items-center gap-2 border-b px-3 py-2"> <h3 className="me-1 text-sm font-semibold text-foreground">{title}</h3> <div role="group" aria-label={msg("log-stream.showLevels", "Show levels")} className="flex items-center gap-1"> {LEVELS.map((level) => ( <button key={level} type="button" aria-pressed={active.has(level)} onClick={() => setActive((set) => { const next = new Set(set) if (next.has(level)) next.delete(level) else next.add(level) return next }) } className={cn( "inline-flex h-7 items-center gap-1.5 rounded-md border px-2 font-mono text-[11px] font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", active.has(level) ? cn("text-foreground", LEVEL_META[level].chip) : "border-dashed text-muted-foreground line-through hover:text-foreground" )} > <span aria-hidden="true" className={cn("size-1.5 rounded-full", LEVEL_META[level].bar)} /> {LEVEL_META[level].label} <span className="text-muted-foreground tabular-nums">{counts[level]}</span> </button> ))} </div> <div className="relative ms-auto min-w-36 flex-1 @xl:max-w-56 @xl:flex-none"> <Search aria-hidden="true" className="pointer-events-none absolute top-1/2 start-2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} aria-label={msg("log-stream.filterLogLines", "Filter log lines")} placeholder={msg("log-stream.filter", "Filter")} className="h-7 w-full rounded-md border bg-background pe-2 ps-7 text-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /> </div> <div className="flex items-center gap-0.5"> <button type="button" aria-pressed={wrap} aria-label={msg("log-stream.wrapLongLines", "Wrap long lines")} title={msg("log-stream.wrapLongLines", "Wrap long lines")} onClick={() => setWrap((w) => !w)} className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent aria-pressed:text-foreground" > <WrapText aria-hidden="true" className="size-3.5" /> </button> <button type="button" aria-pressed={paused} aria-label={paused ? msg("log-stream.resumeStream", "Resume stream") : msg("log-stream.pauseStream", "Pause stream")} title={paused ? msg("log-stream.resumeStream", "Resume stream") : msg("log-stream.pauseStream", "Pause stream")} onClick={() => { setPausedAt(lines.length) setPaused((p) => !p) }} className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent aria-pressed:text-foreground" > {paused ? <Play aria-hidden="true" className="size-3.5" /> : <Pause aria-hidden="true" className="size-3.5" />} </button> <CopyButton size="sm" ariaLabel="Copy visible lines" getValue={copyText} disabled={visible.length === 0} /> {onClear && ( <button type="button" onClick={onClear} className="ms-0.5 h-7 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {msg("log-stream.clear", "Clear")} </button> )} </div> </div> <div className="relative"> <div ref={scroller} role="log" aria-label={msg("log-stream.lines", "{title}, {length} lines", { title, length: visible.length })} aria-live="off" tabIndex={0} onScroll={onScroll} style={{ height }} className="overflow-auto bg-muted/20 py-1 font-mono text-xs leading-5 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50" > {visible.length === 0 ? ( <p className="px-4 py-6 text-center font-sans text-[13px] text-muted-foreground">{lines.length === 0 ? emptyText : "No lines match the filters."}</p> ) : ( visible.map((l) => ( <div key={l.id} data-level={l.level} className={cn( "relative flex gap-3 py-px pe-4 ps-4 hover:bg-accent/60", l.level === "error" && "bg-destructive/5", l.level === "warn" && "bg-chart-3/5", wrap ? "" : "w-max min-w-full" )} > <span aria-hidden="true" className={cn("absolute inset-y-0 start-0 w-0.5", LEVEL_META[l.level].bar)} /> <time dateTime={isoOf(l.time)} className="shrink-0 text-muted-foreground tabular-nums"> {clock(l.time)} </time> <span className="w-11 shrink-0 font-medium text-foreground">{LEVEL_META[l.level].label}</span> {!hideSource && l.source && <span className="hidden w-16 shrink-0 truncate text-muted-foreground @xl:block">{l.source}</span>} <span className={cn("min-w-0 flex-1 text-foreground", wrap ? "break-words whitespace-pre-wrap" : "whitespace-pre")}> <Highlight text={l.message} query={q} /> </span> </div> )) )} </div> {(!atBottom || hiddenNew > 0) && visible.length > 0 && ( <button type="button" onClick={jump} className="absolute end-3 bottom-3 inline-flex h-8 items-center gap-1.5 rounded-full border bg-popover px-3 text-xs font-medium text-popover-foreground shadow-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50" > <ArrowDown aria-hidden="true" className="size-3.5" /> {hiddenNew > 0 ? `${hiddenNew} new ${hiddenNew === 1 ? "line" : "lines"}` : "Jump to latest"} </button> )} </div> <p className="sr-only" role="status" aria-live="polite"> {paused ? "Stream paused" : ""} </p> </div> ) } export { LogStream, clock, type LogStreamProps, type LogLine, type LogLevel }Update the import paths to match your project setup.
Usage
import { LogStream, clock } from "@/components/ballmac/log-stream"The full example is in the Code tab above.
Examples
Filtered build output
Last hour
import { LogStream, type LogLine } from "@/components/ballmac/log-stream"
const base = Date.UTC(2026, 8, 30, 9, 15, 0)
const lines: LogLine[] = [
{ id: 1, level: "info", source: "api", message: "GET /v1/projects 200 in 38 ms" },
{ id: 2, level: "info", source: "api", message: "POST /v1/customers 201 in 112 ms" },
{ id: 3, level: "warn", source: "api", message: "Slow query on customers.by_email took 1.4 s" },
{ id: 4, level: "error", source: "worker", message: "Job invoice.send failed: connect ETIMEDOUT 10.0.4.12:587" },
{ id: 5, level: "debug", source: "worker", message: "Retry scheduled for job invoice.send in 30 s (attempt 2 of 5)" },
{ id: 6, level: "info", source: "api", message: "GET /v1/customers/cus_91 200 in 21 ms" },
{ id: 7, level: "error", source: "api", message: "POST /v1/subscriptions 500: unique constraint violated" },
{ id: 8, level: "info", source: "worker", message: "Job invoice.send succeeded after 2 attempts" },
].map((l, i) => ({ ...l, time: base + i * 4_300 })) as LogLine[]
export default function LogStreamStatic() {
return (
<div className="w-full max-w-2xl">
<LogStream title="Last hour" lines={lines} height="15rem" wrap />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
lines*All lines so far, oldest first. Append to this array as the stream delivers. | LogLine[] | — |
titleHeading in the toolbar. | string | — |
maxLinesMost lines kept on screen; older ones are dropped from view. | number | 1000 |
followStick to the newest line while at the bottom. | boolean | true |
heightHeight of the log area. | string | "20rem" |
wrapWrap long messages instead of scrolling sideways. | boolean | — |
hideSourceHide the source column. | boolean | false |
onClearAdds a Clear button that calls this. | () => void | — |
emptyTextShown when there are no lines yet. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Reaches filters, search, wrap, pause, copy, then the scrollable log (role=log) |
| Screen readers | The log is not announced line by line; pause state is announced; levels are words |
| Color | Level is a word and a bar |
Use with AI
lines is [{ id, time, level, message, source? }], oldest first; append as lines arrive. Follows the tail while you are at the bottom, shows 'N new lines' when paused or scrolled up. maxLines limits what is rendered. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Log Stream (@ballmac/log-stream) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Deploy, build and server logs
- Streaming output from background jobs
Not for
- A terminal with input (terminal)
- Millions of lines (use a virtualized list)
Registry JSON: https://ui.ballmac.com/r/log-stream.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Terminal
A terminal window with command, output, success, error and comment lines, optional per-command copy buttons and a sequenced typing animation that respects reduced motion.
Status Dot
A compact status indicator that always pairs its color with a visible label.
Copy Button
A copy-to-clipboard button with a morphing check icon, an optional label, a fallback for blocked clipboards, an error state and a polite announcement.
API Endpoint
An endpoint reference card: method and path with highlighted parameters, auth, grouped parameter lists, and request and response examples in tabs with copy.