Display

Log Viewer

A runtime log console — level filters with counts, search highlighting with hit-to-hit jumps, tail-follow that pauses on manual scroll, and windowed rendering for huge buffers.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowDown, ArrowDownToLine, ArrowUp, Search, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { LOG_LEVELS, type LogLevel, type LogLine, type LogViewerStatus } from "./log-viewer.contract"

const DEFAULT_MAX_HEIGHT = 480
const DEFAULT_ROW_HEIGHT = 22
const MIN_ROW_HEIGHT = 14
const MIN_MAX_HEIGHT = 96
/** rows kept mounted above/below the viewport so fast scrolling never shows a blank band */
const OVERSCAN = 8
/** px of slack that still counts as "pinned to the bottom" (sub-pixel rounding at odd zoom levels) */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/log-viewer.json

Prompt

Build a React + TypeScript + Tailwind "LogViewer" component with zod and
lucide-react.

Contract
- A zod schema (`logLineSchema` in a sibling contract file) is the single
  source of truth for one line: { id, timeLabel, level: "debug"|"info"|
  "warn"|"error", source?, message, meta?: Record<string, string|number|
  boolean> }. `timeLabel` is ALREADY FORMATTED (e.g. "08:15:03.120") — the
  component never calls new Date() / Intl.*, so server and client render the
  same string. `LOG_LEVELS` is derived from the enum (schema.options), so
  filter order and level styling have one source of truth.
- A separate status union — "loading" | "empty" | "error" | "ready" — is the
  viewer's own render state, independent of any line's `level`.
- Props: lines: LogLine[]; status; levels?: LogLevel[] + onLevelsChange?;
  query?: string + onQueryChange?; follow?: boolean + onFollowChange?;
  wrap?: boolean (false); showTimestamps?: boolean (true); maxHeight?:
  number (480); rowHeight?: number (22); onRetry?; onClear?; emptyState?:
  ReactNode; label?: string; skeletonRows?: number (8);
  onRenderedRangeChange?: (range: {start, end, rendered}) => void; className.
- levels / query / follow are hybrid controlled: the prop wins when passed,
  otherwise internal state runs them (defaults: all four levels, empty
  query, follow on). Passing `follow` WITHOUT `onFollowChange` means the
  viewer can never pause itself — call that out, it is the one footgun.
- Numeric props are clamped (rowHeight >= 14, maxHeight >= 96): rowHeight 0
  would divide-by-zero the windowing math and maxHeight 0 would swallow the
  whole log.

Behavior
- Four first-class branches: loading → skeleton rows built with the same
  gutter widths and rowHeight as the real log (nothing jumps when the stream
  connects); empty → "no output yet" slot, overridable via `emptyState`;
  error → message + "Try again" only when `onRetry` is passed; ready → the
  toolbar plus the scrollable log.
- Follow (tail) is a two-state machine driven by ONE measurement inside the
  scroll handler: distanceToBottom = scrollHeight - scrollTop - clientHeight,
  where 8px or less counts as pinned. Scrolling away from the bottom flips
  follow off and reveals a "Follow paused · Jump to latest" pill; scrolling
  back to the bottom (or clicking the pill) flips it back on. While follow
  is on, a LAYOUT effect sets scrollTop = scrollHeight after every content
  change (new lines, level toggle, wrap toggle) so the newest line is
  already in place on the frame it appears. Programmatic pins always land at
  distance 0, so they can only ever resume follow — no "was this scroll
  mine?" flag is needed. Jumping to a search hit scrolls upward and
  therefore pauses follow on purpose: you are inspecting, not tailing.
- Level filter: one toggle per level carrying that level's count across the
  WHOLE buffer (counts never change when you filter). Filtered rows keep
  their original 1-based position in `lines` as their line number — hiding
  debug must not renumber anything. Turning every level off renders a
  "no lines at the selected levels" body with a "Show all levels" reset,
  which is a different message from the status="empty" branch.
- Search highlights, it does not filter. Every case-insensitive hit in
  `message` is wrapped in a mark element; the active hit's row is tinted and
  ringed and its marks invert. A role="status" counter reads "hit / total".
  Typing jumps to the first hit (find-in-page behaviour); Enter / ArrowDown
  go to the next hit, Shift+Enter / ArrowUp to the previous (wrapping
  around), Escape clears. The active index is reset during render via
  adjust-state-on-prop-change (never setState-in-effect) and clamped as a
  derived value, so a shrinking buffer can't leave "7/3" on screen.
- Windowing: rows are a fixed rowHeight, so the visible slice is
  floor(scrollTop / rowHeight) - overscan … ceil((scrollTop +
  viewportHeight) / rowHeight) + overscan, with two aria-hidden spacer divs
  standing in for the rows above and below. viewportHeight comes from a
  ResizeObserver on the scroll element (observe() fires once immediately =
  the first measurement), not from the maxHeight prop. Windowing is ACTIVE
  ONLY WHILE wrap === false — wrapped rows have no knowable height, so
  `wrap` renders every filtered row instead.
- Long lines: wrap=false keeps a row on one line (whitespace-pre, every
  gutter shrink-0) and the viewport scrolls horizontally; wrap=true makes
  the message column flex-1 min-w-0 whitespace-pre-wrap, so continuation
  lines stay aligned under the message instead of under the line number.
- Accessibility: the scroll container is focusable (tabIndex=0) with an
  aria-label and a focus-visible ring; rows are role="listitem" inside a
  role="list" with aria-posinset/aria-setsize (spacers are aria-hidden plus
  role="presentation" so the list → listitem ownership chain survives).
  A visually hidden aria-live="polite" region announces ONLY newly arrived
  error lines, throttled to at most one burst per 2s ("3 new error lines ·
  <time> <message>") — announcing every line would read a running log out
  loud forever. The announcer re-anchors silently on mount, on a status
  change and when the buffer is cleared or replaced, so it never dumps the
  initial batch.
- Cleanup: the ResizeObserver disconnects and the announcement timer is
  cleared on unmount.

Rendering & styling
- Semantic tokens only, and deliberately NO chart tokens for text — level
  contrast comes from token + weight + a left bar so it survives a
  monochrome (chroma-0) palette in both themes: error = text-destructive +
  border-l-destructive + bg-destructive/5, warn = text-foreground
  font-medium + border-l-foreground/40, info = text-muted-foreground,
  debug = text-muted-foreground/70 with an italic level tag. Marks use
  bg-primary/20 (inactive) and bg-primary + text-primary-foreground
  (active). Surfaces: bg-card, bg-muted (skeleton, hover), border, and
  focus-visible:ring-2 ring-ring on every control. cn() merges the consumer
  className into the root.
- Monospace throughout, with tabular-nums on the line number, timestamp and
  counters; line-height is set to exactly rowHeight so glyphs sit optically
  centred and every wrapped line is one row tall. The line-number gutter is
  select-none, so copying a block of log yields the text and not the
  numbers.
- The only animation is the loading pulse and it carries
  motion-reduce:animate-none; scroll pins are instant (never smooth), which
  is both correct for a log and reduced-motion safe.
- overflow-anchor: none on the scroll container — the browser's scroll
  anchoring fights an append-only log and yanks the viewport around.

Customization levers
- Density: rowHeight (22 → 18 for a dense console, 26 for comfortable) is
  the single knob; line-height, the windowing math and the skeleton all
  follow it. maxHeight sizes the viewport; the log scrolls past it.
- Columns: drop `showTimestamps` for a bare console, remove the source span
  if your stream has none, or add a column by inserting another shrink-0
  span before the message (keep gutters shrink-0 + select-none so wrap
  alignment survives).
- Level vocabulary: change the zod enum (e.g. add "trace" / "fatal") and add
  matching entries to LEVEL_ROW / LEVEL_TEXT / LEVEL_TAG — filters, counts
  and announcements are all derived from the enum.
- Search scope: matching and highlighting both read `message` only, on
  purpose (a hit you cannot see is worse than no hit). To search `source` or
  `meta` too, extend the haystack AND highlight those spans in the same
  change.
- Follow policy: raise the 8px bottom epsilon for touch devices, or make
  follow strictly controlled with `follow` + `onFollowChange` and decide
  yourself whether a search jump should pause it.
- Announcements: swap the error-only filter for warn+error, or change the 2s
  throttle window; keep some throttle or a live tail floods a screen reader.
- Huge buffers: keep wrap off (windowing needs the fixed row height) and cap
  the array upstream with a rolling last-N buffer — the component renders
  every line it is given.

Concepts

  • Tail-follow with auto-pause — following isn't a button you toggle, it's a derived reading of one number: scrollHeight - scrollTop - clientHeight. Scroll up and the viewer stops chasing the tail; scroll back to the bottom and it resumes. Because every programmatic pin lands at distance 0, the component never has to ask "was that scroll mine or the user's?".
  • Windowing is a contract, not a mode — a fixed row height is what makes a 5 000-line buffer cheap: only the visible slice plus overscan exists in the DOM, and two aria-hidden spacers hold up the scroll height. Turning wrap on gives that up knowingly, because wrapped rows have no knowable height.
  • Original line numbering — the number in the gutter is the line's position in the buffer you passed in, not its position after filtering. Hiding debug must never make line 412 look like line 118, or the number stops being usable for cross-referencing.
  • Highlight, don't filter — search keeps every line on screen and wraps hits in <mark> with a "hit / total" counter, while Enter and the arrow keys walk hit to hit. Filtering by the search term would destroy the surrounding context, which is the entire reason you are reading a log.
  • Announce the exception, not the stream — a polite live region that fires on every appended line reads a running log out loud forever. Only newly arrived error lines are announced, batched into at most one burst every two seconds.
  • Two different kinds of "nothing here"status="empty" means the stream produced no output; a fully filtered buffer is a ready log with its own message and a "Show all levels" reset. Collapsing them would tell the user their job printed nothing when they had simply hidden every level.

On This Page