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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/log-viewer.jsonPrompt
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-hiddenspacers hold up the scroll height. Turningwrapon 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
debugmust 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
errorlines 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.
Notification Center
An inbox popover for the app bell — grouped rows, tab filters, per-row and bulk read management, clipping-aware placement, and real loading/empty/error states.
Comment Thread
A nested discussion tree — collapsible reply subtrees, optimistic voting, an inline reply composer with a posting/failed lifecycle, and a depth cap that hands deep threads off to a permalink.