Skip to contentVibraUI
Data display

Log viewer

A tail of log lines with level filters, a search box, and a follow-the-bottom mode.

A client component. filterLogs is exported and pure: a line survives when its level is in the set and the trimmed, lower-cased query appears in its message or its source. The body renders at most 1,000 lines — the newest ones — and says so above the list when it drops any; past that it is the DOM, not the data, that slows a page down. follow sticks to the bottom as lines arrive, but only while the reader is already there: scrolling up parks the view, and it resumes on its own once you scroll back down. The list is a role="log" named with its visible count, announced politely only while following, since a static tail is not news. The source column is a fixed width so the messages beside it line up; sourceWidth says how many characters it holds, since only the caller knows how long its service names run. The level toggles are one labelled group, and each level takes its colour from the status tokens — debug muted, info info, warn warning, error danger. wrap sets the wrap toggle's starting position rather than driving it. Timestamps are formatted in the reader's own timezone and suppress the hydration warning that follows from it.

Install

npx shadcn@latest add @vibra/log-viewer

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
lines{ id?: string; timestamp?: Date | string; level: LogLevel; message: string; source?: string }[]—The lines, oldest first. An id keeps a line's identity as the tail moves.
heightnumber320Pixels the body scrolls inside.
filterablebooleantrueShows the level toggles, the search box, the wrap toggle, and the count.
wrapbooleanfalseThe wrap toggle's starting position.
followbooleanfalseSticks to the bottom as lines arrive, unless the reader has scrolled up.
showTimestampsbooleantrueShows the HH:MM:SS column for lines that carry one.
sourceWidthnumber—Width of the source column in characters — the body is monospace, so this is how many letters of a service name survive. Left off, the column holds about twelve.
emptyMessageReact.ReactNodeNo output yet.Shown when there are no lines at all; a filtered-empty view says so instead.
filterLogs(lines: LogLine[], levels: Set<LogLevel>, query: string) => LogLine[]—The filter the toolbar drives, exported so a server can apply the same rule.
LOG_LEVELSreadonly LogLevel[]—The four levels, quietest first — the order the toolbar toggles appear in.
classNamestring—Merged onto the div root; the remaining div props are spread onto it too.

Dependencies

Source

components/ui/log-viewer.tsx
"use client"

import * as React from "react"
import { WrapTextIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export type LogLevel = "debug" | "info" | "warn" | "error"

/** The four levels, quietest first — the order the toolbar toggles appear in. */
export const LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"]

// Straight from the status tokens, so a warning in a log reads the same as a
// warning anywhere else on the page.
const LEVEL_COLORS: Record<LogLevel, string> = {
  debug: "text-muted-foreground",
  info: "text-info",
  warn: "text-warning",
  error: "text-danger",
}

// Past this the DOM, not the data, is what slows a page down. The newest lines
// are the ones kept, and the header says how many were dropped.
const MAX_RENDERED = 1000

export type LogLine = {
  id?: string
  timestamp?: Date | string
  level: LogLevel
  message: string
  /** The subsystem that emitted the line — a service, a worker, a file. */
  source?: string
}

/** Keeps the lines whose level is in `levels` and whose message or source contains `query`. */
export function filterLogs(lines: LogLine[], levels: Set<LogLevel>, query: string): LogLine[] {
  const needle = query.trim().toLowerCase()
  return lines.filter((line) => {
    if (!levels.has(line.level)) return false
    if (needle === "") return true
    return (
      line.message.toLowerCase().includes(needle) ||
      (line.source?.toLowerCase().includes(needle) ?? false)
    )
  })
}

// hourCycle "h23", not hour12: false — the latter leaves the hour after
// midnight up to the engine, and some ICU builds render it as 24.
const TIME_FORMAT = new Intl.DateTimeFormat("en-US", {
  hour: "2-digit",
  minute: "2-digit",
  second: "2-digit",
  hourCycle: "h23",
})

function formatTimestamp(timestamp: Date | string): string {
  const date = timestamp instanceof Date ? timestamp : new Date(timestamp)
  return Number.isNaN(date.getTime()) ? "--:--:--" : TIME_FORMAT.format(date)
}

export type LogViewerProps = React.ComponentProps<"div"> & {
  lines: LogLine[]
  /** Pixels the body scrolls inside. */
  height?: number
  /** Shows the level toggles, the search box, the wrap toggle, and the count. */
  filterable?: boolean
  /** The wrap toggle's starting position. */
  wrap?: boolean
  /** Sticks to the bottom as lines arrive, unless the reader has scrolled up. */
  follow?: boolean
  showTimestamps?: boolean
  /** Width of the source column in characters. The body is monospace, so this is how many letters of a service name survive; left off, the column holds about twelve. */
  sourceWidth?: number
  emptyMessage?: React.ReactNode
}

/** A tail of log lines with level filters, a search box, and an optional follow-the-bottom mode. */
function LogViewer({
  className,
  lines,
  height = 320,
  filterable = true,
  wrap = false,
  follow = false,
  showTimestamps = true,
  sourceWidth,
  emptyMessage = "No output yet.",
  ...props
}: LogViewerProps) {
  const [levels, setLevels] = React.useState<LogLevel[]>(() => [...LOG_LEVELS])
  const [query, setQuery] = React.useState("")
  const [wrapped, setWrapped] = React.useState(wrap)

  const levelSet = React.useMemo(() => new Set(levels), [levels])
  const filtered = React.useMemo(
    () => filterLogs(lines, levelSet, query),
    [lines, levelSet, query]
  )
  const shown = filtered.length > MAX_RENDERED ? filtered.slice(-MAX_RENDERED) : filtered
  const dropped = filtered.length - shown.length

  const bodyRef = React.useRef<HTMLDivElement>(null)
  // A reader who has scrolled up is reading; following would yank them back.
  const atBottom = React.useRef(true)

  React.useEffect(() => {
    if (!follow) return
    const body = bodyRef.current
    if (!body || !atBottom.current) return
    body.scrollTop = body.scrollHeight
  }, [follow, lines.length])

  const filteredEmpty = lines.length > 0 && filtered.length === 0

  return (
    <div
      data-slot="log-viewer"
      data-follow={follow || undefined}
      data-wrap={wrapped || undefined}
      className={cn("flex flex-col overflow-hidden rounded-lg border bg-muted/40", className)}
      {...props}
    >
      {filterable ? (
        <div
          data-slot="log-viewer-toolbar"
          className="flex flex-wrap items-center gap-2 border-b bg-background/60 p-2"
        >
          <ToggleGroup
            multiple
            // The primitive already sets role="group"; spelling it out here
            // keeps the four toggles a named group even if it stops.
            role="group"
            aria-label="Log levels"
            size="sm"
            variant="outline"
            spacing={0}
            value={levels}
            onValueChange={(next) => setLevels(next as LogLevel[])}
          >
            {LOG_LEVELS.map((level) => (
              <ToggleGroupItem key={level} value={level} className="font-mono text-xs">
                <span
                  aria-hidden="true"
                  className={cn("size-1.5 rounded-full bg-current", LEVEL_COLORS[level])}
                />
                {level}
              </ToggleGroupItem>
            ))}
          </ToggleGroup>

          <Input
            type="search"
            aria-label="Filter log lines"
            placeholder="Filter…"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            className="h-7 w-32 flex-1 sm:w-44 sm:flex-none"
          />

          <ToggleGroup
            multiple
            size="sm"
            variant="outline"
            value={wrapped ? ["wrap"] : []}
            onValueChange={(next) => setWrapped(next.includes("wrap"))}
          >
            <ToggleGroupItem value="wrap" aria-label="Wrap long lines">
              <WrapTextIcon aria-hidden="true" />
            </ToggleGroupItem>
          </ToggleGroup>

          <Badge variant="outline" className="ms-auto tabular-nums">
            {`${formatNumber(filtered.length, { maximumFractionDigits: 0 })} of ${formatNumber(lines.length, { maximumFractionDigits: 0 })}`}
          </Badge>
        </div>
      ) : null}

      {dropped > 0 ? (
        <p
          data-slot="log-viewer-truncation"
          className="border-b px-3 py-1 text-xs text-muted-foreground"
        >
          {`Showing the last ${formatNumber(MAX_RENDERED, { maximumFractionDigits: 0 })} of ${formatNumber(filtered.length, { maximumFractionDigits: 0 })} lines.`}
        </p>
      ) : null}

      <div
        ref={bodyRef}
        data-slot="log-viewer-body"
        role="log"
        // A tail that is being followed is news; a static one is not, and
        // announcing every line would bury everything else on the page.
        aria-live={follow ? "polite" : "off"}
        // The filtered count, not the rendered one, so the name and the
        // toolbar's badge always agree; the cap is the truncation note's job.
        aria-label={`Log output, ${formatNumber(filtered.length, { maximumFractionDigits: 0 })} ${
          filtered.length === 1 ? "line" : "lines"
        }`}
        tabIndex={0}
        onScroll={(event) => {
          const el = event.currentTarget
          atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8
        }}
        className="overflow-auto py-1 font-mono text-xs leading-5 focus-ring"
        style={{ height: `${height}px` }}
      >
        {shown.length === 0 ? (
          <p className="px-3 py-6 text-center text-muted-foreground">
            {filteredEmpty ? "No lines match the current filters." : emptyMessage}
          </p>
        ) : (
          shown.map((line, index) => (
            <div
              key={line.id ?? index}
              data-slot="log-viewer-line"
              data-level={line.level}
              className="flex gap-3 px-3 py-px hover:bg-accent/50"
            >
              {showTimestamps && line.timestamp !== undefined ? (
                <span
                  data-slot="log-viewer-timestamp"
                  // A clock time is read in the reader's own zone, and a
                  // server rendering the same line is rarely in it.
                  suppressHydrationWarning
                  className="shrink-0 tabular-nums text-muted-foreground"
                >
                  {formatTimestamp(line.timestamp)}
                </span>
              ) : null}
              <span
                data-slot="log-viewer-level"
                className={cn("w-10 shrink-0", LEVEL_COLORS[line.level])}
              >
                {line.level}
              </span>
              {line.source ? (
                <span
                  data-slot="log-viewer-source"
                  // The column is fixed so the messages line up; how wide it
                  // has to be is a property of the names in it, which only the
                  // caller knows.
                  className={cn(
                    "shrink-0 truncate text-muted-foreground",
                    sourceWidth === undefined && "w-24"
                  )}
                  style={sourceWidth === undefined ? undefined : { width: `${sourceWidth}ch` }}
                >
                  {line.source}
                </span>
              ) : null}
              <span
                data-slot="log-viewer-message"
                className={cn(
                  "min-w-0 text-foreground",
                  wrapped ? "break-words whitespace-pre-wrap" : "whitespace-pre"
                )}
              >
                {line.message}
              </span>
            </div>
          ))
        )}
      </div>
    </div>
  )
}

export { LogViewer }