Log Viewer

A streaming console with filtering, level facets and follow mode.

Build output
Following

400 of 400 lines

infoServer listening on http://localhost:3000
debugResolved 782 modules in 412ms
infoGET /api/workspaces 200 in 34ms
warnSlow query took 1240ms
infoGET /api/deals 200 in 61ms
errorConnection refused
infoRetrying connection to db-replica-1
debugPool size 8, idle 2
infoPOST /api/deals 201 in 89ms
tracecache hit workspaces:acme
infoServer listening on http://localhost:3000
debugResolved 782 modules in 412ms
infoGET /api/workspaces 200 in 34ms
warnSlow query took 1240ms
infoGET /api/deals 200 in 61ms
errorConnection refused
infoRetrying connection to db-replica-1
debugPool size 8, idle 2
infoPOST /api/deals 201 in 89ms
tracecache hit workspaces:acme
infoServer listening on http://localhost:3000
debugResolved 782 modules in 412ms
infoGET /api/workspaces 200 in 34ms
warnSlow query took 1240ms
infoGET /api/deals 200 in 61ms
errorConnection refused
infoRetrying connection to db-replica-1
debugPool size 8, idle 2
infoPOST /api/deals 201 in 89ms

Installation

Terminal
pnpm dlx @dowel-ui/cli add log-viewer

npm packages installed: @tanstack/react-virtual.

Accessibility

role="log" implies aria-live="polite", which is right for a few events and unusable for a console — a screen reader would read every line of a build and nothing else would be audible. Announcing is off by default and opt-in. Virtualization is the harder trade: most rows are not in the DOM, so assistive technology cannot reach them, which is why the component takes an onDownload escape rather than pretending the virtual window is the whole log. Levels are written in text as well as coloured, matches use mark elements rather than a background colour, and an invalid pattern is announced instead of silently showing an empty log that reads as "nothing matched".

Props

LogViewer

PropTypeDefault
label (required)

Names the region.

string
lines (required)VisibleLine[]
announce

Read new lines aloud. Off by default — a console that announces every line makes a screen reader useless for anything else.

booleanfalse
heightnumber | string360
onDownload

The way out of the virtual window, for anyone who needs the whole log.

() => void
rowHeight

Row height in pixels. Rows are one line; expansion is measured.

number22

Plus every attribute of <div> except children.

LogViewerRow

PropTypeDefault
line (required)VisibleLine
expandedbooleanfalse
onToggle() => void

LogViewerToolbar

PropTypeDefault
counts (required)Map<LogLevel, number>
levels (required)Set<LogLevel>
onQueryChange (required)(query: string) => void
onRegexChange (required)(regex: boolean) => void
onToggleLevel (required)(level: LogLevel) => void
query (required)string
regex (required)boolean
showing (required)

Shown as "N of M lines".

number
total (required)number
invalidPatternbooleanfalse

Plus every attribute of <div> except onChange.

Quality

9/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedfails
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add log-viewer writes into your project, with imports rewritten to your own path alias.

ui/log-stream.ts
"use client";

import { useCallback, useMemo, useState } from "react";

/**
 * Filtering and match-finding for a log stream.
 *
 * Separate from the view because it is pure and worth testing without a DOM,
 * and because the awkward parts are here rather than in the rendering: a bad
 * regex must not throw while someone is halfway through typing it, and a match
 * has to be located precisely enough to highlight rather than merely detected.
 */

export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";

export const LOG_LEVELS: readonly LogLevel[] = [
  "trace",
  "debug",
  "info",
  "warn",
  "error",
  "fatal",
];

export interface LogLine {
  id: string;
  message: string;
  level?: LogLevel;
  /** ISO timestamp, or anything the consumer wants shown in the gutter. */
  timestamp?: string;
  /** Structured fields, revealed when the row is expanded. */
  fields?: Record<string, unknown>;
}

/** A [start, end) slice of a message that matched the filter. */
export type MatchRange = readonly [number, number];

export interface FilterState {
  /** Substring, or a pattern when `regex` is on. */
  query: string;
  regex: boolean;
  /** Levels to show. Empty means all of them. */
  levels: Set<LogLevel>;
}

/**
 * Compiles the query.
 *
 * Returns null for an invalid pattern rather than throwing: the query is being
 * typed, so it spends most of its life syntactically incomplete, and a viewer
 * that crashes on "(" is unusable.
 */
export function compileQuery(query: string, regex: boolean): RegExp | null {
  if (query.length === 0) return null;
  try {
    return regex
      ? new RegExp(query, "gi")
      : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
  } catch {
    return null;
  }
}

/** Every match in a line, so each can be highlighted rather than just the first. */
export function findMatches(message: string, pattern: RegExp | null): MatchRange[] {
  if (!pattern) return [];

  const ranges: MatchRange[] = [];
  // Fresh lastIndex: the pattern is reused across lines and a global regex
  // carries position between calls, which would skip matches in later lines.
  pattern.lastIndex = 0;

  let match = pattern.exec(message);
  while (match !== null) {
    // A pattern that can match empty — "a*" — would loop forever otherwise.
    if (match[0].length === 0) {
      pattern.lastIndex += 1;
    } else {
      ranges.push([match.index, match.index + match[0].length]);
    }
    match = pattern.exec(message);
  }

  return ranges;
}

/** Splits a message into alternating plain and matched segments. */
export function segment(
  message: string,
  ranges: MatchRange[],
): { text: string; match: boolean }[] {
  if (ranges.length === 0) return [{ text: message, match: false }];

  const parts: { text: string; match: boolean }[] = [];
  let cursor = 0;

  for (const [start, end] of ranges) {
    if (start > cursor) parts.push({ text: message.slice(cursor, start), match: false });
    parts.push({ text: message.slice(start, end), match: true });
    cursor = end;
  }
  if (cursor < message.length) parts.push({ text: message.slice(cursor), match: false });

  return parts;
}

export interface VisibleLine extends LogLine {
  matches: MatchRange[];
}

export interface UseLogStreamOptions {
  lines: LogLine[];
  /** Levels present but unchecked are hidden. Empty shows everything. */
  initialLevels?: Set<LogLevel>;
}

export function useLogStream({ lines, initialLevels }: UseLogStreamOptions) {
  const [query, setQuery] = useState("");
  const [regex, setRegex] = useState(false);
  const [levels, setLevels] = useState<Set<LogLevel>>(initialLevels ?? new Set());

  const pattern = useMemo(() => compileQuery(query, regex), [query, regex]);

  // An invalid pattern is reported, not swallowed: with no feedback the reader
  // sees an empty log and concludes nothing matched.
  const invalidPattern = regex && query.length > 0 && pattern === null;

  const visible = useMemo<VisibleLine[]>(() => {
    const result: VisibleLine[] = [];

    for (const line of lines) {
      if (levels.size > 0 && line.level && !levels.has(line.level)) continue;

      const matches = findMatches(line.message, pattern);
      // A query with no match hides the line; an invalid pattern hides nothing,
      // because the reader has not finished saying what they want yet.
      if (pattern && matches.length === 0) continue;

      result.push({ ...line, matches });
    }

    return result;
  }, [lines, levels, pattern]);

  const counts = useMemo(() => {
    const byLevel = new Map<LogLevel, number>();
    for (const line of lines) {
      if (!line.level) continue;
      byLevel.set(line.level, (byLevel.get(line.level) ?? 0) + 1);
    }
    return byLevel;
  }, [lines]);

  const toggleLevel = useCallback((level: LogLevel) => {
    setLevels((current) => {
      const next = new Set(current);
      if (next.has(level)) next.delete(level);
      else next.add(level);
      return next;
    });
  }, []);

  return {
    query,
    setQuery,
    regex,
    setRegex,
    levels,
    toggleLevel,
    setLevels,
    visible,
    counts,
    invalidPattern,
    total: lines.length,
  };
}
ui/log-viewer.tsx
"use client";

import { useVirtualizer } from "@tanstack/react-virtual";
import {
  useEffect,
  useId,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type ReactNode,
} from "react";

import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import { LOG_LEVELS, segment, type LogLevel, type VisibleLine } from "./log-stream";

/**
 * A streaming console: filter, follow, expand.
 *
 * The incumbent is react-lazylog, which was last published in 2022, is built on
 * react-virtualized, and cannot run on React 19 — while still taking about
 * 15,000 downloads a week. This is for those people.
 *
 * Two accessibility decisions worth stating rather than discovering.
 *
 * `role="log"` implies `aria-live="polite"`, which is right for a handful of
 * events and catastrophic for a console: a screen reader would read every line
 * of a build as it scrolls past, and nothing else would be audible. Announcing
 * is therefore off by default and opt-in through `announce`. The role stays,
 * because it still describes what the region is.
 *
 * And virtualization means most rows are not in the DOM. That is a real trade,
 * not an implementation detail: assistive technology cannot reach what is not
 * rendered, so a reader who needs the whole log needs an escape — which is what
 * `onDownload` is for. A log viewer with no way out of the virtual window is
 * not accessible however good its ARIA is.
 */

const LEVEL_STYLES: Record<LogLevel, string> = {
  trace: "text-muted-foreground",
  debug: "text-muted-foreground",
  info: "text-foreground",
  warn: "text-warning",
  error: "text-destructive",
  fatal: "text-destructive font-semibold",
};

/** Distance from the bottom, in pixels, that still counts as "at the end". */
const FOLLOW_THRESHOLD = 24;

export interface LogViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
  lines: VisibleLine[];
  /** Names the region. */
  label: string;
  /** Row height in pixels. Rows are one line; expansion is measured. */
  rowHeight?: number;
  height?: number | string;
  /**
   * Read new lines aloud. Off by default — a console that announces every line
   * makes a screen reader useless for anything else.
   */
  announce?: boolean;
  /** The way out of the virtual window, for anyone who needs the whole log. */
  onDownload?: () => void;
  children?: ReactNode;
}

export function LogViewer({
  className,
  lines,
  label,
  rowHeight = 22,
  height = 360,
  announce = false,
  onDownload,
  children,
  ...props
}: LogViewerProps) {
  const scrollRef = useRef<HTMLDivElement | null>(null);
  const [following, setFollowing] = useState(true);
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
  const labelId = useId();

  const virtualizer = useVirtualizer({
    count: lines.length,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => rowHeight,
    overscan: 12,
    // Without a starting rect the virtualizer renders nothing until a
    // ResizeObserver fires, so the first paint is an empty box. Seeding it from
    // the declared height means rows are there immediately, and it is measured
    // properly a moment later.
    initialRect: { width: 0, height: typeof height === "number" ? height : 360 },
  });

  // Follow mode, hand-written rather than delegated. A spring-anchoring library
  // wants to own the scroll container and so does the virtualizer, and the two
  // fight; this is scrollToIndex on append plus a proximity check.
  useEffect(() => {
    if (!following || lines.length === 0) return;
    virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
  }, [following, lines.length, virtualizer]);

  function handleScroll() {
    const element = scrollRef.current;
    if (!element) return;

    const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
    // Scrolling up detaches, because the reader is looking at something and
    // yanking them back to the tail would lose it. Returning to the bottom
    // re-attaches, which is the gesture people already expect.
    setFollowing(distance <= FOLLOW_THRESHOLD);
  }

  function toggleExpanded(id: string) {
    setExpanded((current) => {
      const next = new Set(current);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }

  const items = virtualizer.getVirtualItems();

  return (
    <div data-slot="log-viewer" className={cn("flex flex-col gap-2", className)} {...props}>
      <div className="flex flex-wrap items-center justify-between gap-2">
        <span id={labelId} className="text-sm font-medium">
          {label}
        </span>
        <div className="flex items-center gap-2">
          {!following ? (
            <button
              type="button"
              data-slot="log-viewer-jump"
              onClick={() => {
                setFollowing(true);
              }}
              className={cn(
                "rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
                "transition-colors hover:bg-accent hover:text-accent-foreground",
                focusRing,
              )}
            >
              Jump to latest
            </button>
          ) : (
            <span className="text-xs text-muted-foreground">Following</span>
          )}
          {onDownload ? (
            <button
              type="button"
              data-slot="log-viewer-download"
              onClick={onDownload}
              className={cn(
                "rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
                "transition-colors hover:bg-accent hover:text-accent-foreground",
                focusRing,
              )}
            >
              Download full log
            </button>
          ) : null}
        </div>
      </div>

      {children}

      <div
        ref={scrollRef}
        onScroll={handleScroll}
        role="log"
        aria-labelledby={labelId}
        // Explicitly off unless asked for: role="log" implies polite, and a
        // console that reads every line aloud drowns out everything else.
        aria-live={announce ? "polite" : "off"}
        // A scrollable region must be focusable, or its content is unreachable
        // without a pointer — WCAG 2.1.1, and axe's scrollable-region-focusable
        // requires exactly this. jsx-a11y's heuristic cannot see that this
        // element scrolls, so here the requirement outranks the rule.
        // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
        tabIndex={0}
        style={{ height }}
        className={cn(
          "overflow-auto rounded-lg border border-border bg-muted/30 font-mono text-xs",
          focusRing,
        )}
      >
        {lines.length === 0 ? (
          <p className="p-3 text-muted-foreground">No lines match the current filter.</p>
        ) : (
          <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
            {items.map((item) => {
              const line = lines[item.index];
              if (!line) return null;

              return (
                <div
                  key={line.id}
                  ref={virtualizer.measureElement}
                  data-index={item.index}
                  data-slot="log-viewer-row"
                  data-level={line.level}
                  className="absolute start-0 top-0 w-full"
                  style={{ transform: `translateY(${String(item.start)}px)` }}
                >
                  <LogViewerRow
                    line={line}
                    expanded={expanded.has(line.id)}
                    onToggle={() => {
                      toggleExpanded(line.id);
                    }}
                  />
                </div>
              );
            })}
          </div>
        )}
      </div>
    </div>
  );
}

export interface LogViewerRowProps {
  line: VisibleLine;
  expanded?: boolean;
  onToggle?: () => void;
}

/**
 * One line.
 *
 * Exported because the virtualized container cannot be measured outside a real
 * browser — jsdom reports every box as zero — so the row's behaviour would
 * otherwise be untestable. It is also genuinely useful on its own for anyone
 * rendering a short log without virtualization.
 */
export function LogViewerRow({ line, expanded = false, onToggle }: LogViewerRowProps) {
  const hasFields = line.fields !== undefined && Object.keys(line.fields).length > 0;
  const parts = segment(line.message, line.matches);

  return (
    <div className="px-3 py-0.5 hover:bg-accent/40">
      <div className="flex items-baseline gap-2">
        {line.timestamp ? (
          <time className="shrink-0 text-muted-foreground tabular-nums">{line.timestamp}</time>
        ) : null}

        {line.level ? (
          // The level in text, not a coloured bar. Colour alone cannot
          // distinguish warn from error for everyone reading.
          <span className={cn("w-11 shrink-0 uppercase", LEVEL_STYLES[line.level])}>
            {line.level}
          </span>
        ) : null}

        <span
          className={cn(
            "min-w-0 flex-1 break-all whitespace-pre-wrap",
            line.level && LEVEL_STYLES[line.level],
          )}
        >
          {parts.map((part, index) =>
            part.match ? (
              // A mark element, so the match is conveyed structurally rather
              // than only as a background colour.
              <mark key={index} className="rounded-[2px] bg-warning/35 text-inherit">
                {part.text}
              </mark>
            ) : (
              <span key={index}>{part.text}</span>
            ),
          )}
        </span>

        {hasFields ? (
          <button
            type="button"
            data-slot="log-viewer-expand"
            aria-expanded={expanded}
            onClick={onToggle}
            className={cn(
              "shrink-0 rounded px-1 text-2xs text-muted-foreground",
              "transition-colors hover:text-foreground",
              focusRing,
              disabledStyles,
            )}
          >
            {expanded ? "Hide fields" : "Fields"}
          </button>
        ) : null}
      </div>

      {expanded && line.fields ? (
        <dl
          data-slot="log-viewer-fields"
          className="ms-4 mt-1 mb-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-s border-border ps-3"
        >
          {Object.entries(line.fields).map(([key, value]) => (
            <div key={key} className="contents">
              <dt className="text-muted-foreground">{key}</dt>
              <dd className="m-0 break-all">
                {typeof value === "string" ? value : JSON.stringify(value)}
              </dd>
            </div>
          ))}
        </dl>
      ) : null}
    </div>
  );
}

export interface LogViewerToolbarProps extends Omit<ComponentPropsWithRef<"div">, "onChange"> {
  query: string;
  onQueryChange: (query: string) => void;
  regex: boolean;
  onRegexChange: (regex: boolean) => void;
  levels: Set<LogLevel>;
  onToggleLevel: (level: LogLevel) => void;
  counts: Map<LogLevel, number>;
  invalidPattern?: boolean;
  /** Shown as "N of M lines". */
  showing: number;
  total: number;
}

export function LogViewerToolbar({
  className,
  query,
  onQueryChange,
  regex,
  onRegexChange,
  levels,
  onToggleLevel,
  counts,
  invalidPattern = false,
  showing,
  total,
  ...props
}: LogViewerToolbarProps) {
  const filterId = useId();
  const errorId = useId();

  return (
    <div
      data-slot="log-viewer-toolbar"
      className={cn("flex flex-wrap items-center gap-2", className)}
      {...props}
    >
      <div className="flex min-w-0 flex-1 items-center gap-2">
        <label htmlFor={filterId} className="sr-only">
          Filter log
        </label>
        <input
          id={filterId}
          type="search"
          value={query}
          placeholder={regex ? "Pattern…" : "Filter…"}
          aria-invalid={invalidPattern || undefined}
          aria-describedby={invalidPattern ? errorId : undefined}
          onChange={(event) => {
            onQueryChange(event.target.value);
          }}
          className={cn(
            "min-w-32 flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm",
            invalidPattern && "border-destructive",
            focusRing,
          )}
        />
        <label className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
          <input
            type="checkbox"
            checked={regex}
            onChange={(event) => {
              onRegexChange(event.target.checked);
            }}
            className={cn("size-3.5 rounded border-input", focusRing)}
          />
          Regex
        </label>
      </div>

      <div className="flex flex-wrap items-center gap-1">
        {LOG_LEVELS.filter((level) => counts.has(level)).map((level) => {
          const on = levels.size === 0 || levels.has(level);
          return (
            <button
              key={level}
              type="button"
              data-slot="log-viewer-facet"
              aria-pressed={on}
              onClick={() => {
                onToggleLevel(level);
              }}
              className={cn(
                "rounded-md border px-1.5 py-0.5 font-mono text-2xs uppercase transition-colors",
                on
                  ? "border-transparent bg-secondary text-secondary-foreground"
                  : "border-input bg-background text-muted-foreground line-through",
                focusRing,
              )}
            >
              {level} {counts.get(level) ?? 0}
            </button>
          );
        })}
      </div>

      <p className="w-full text-xs text-muted-foreground tabular-nums">
        {invalidPattern ? (
          // Said out loud rather than shown as an empty log, which reads as
          // "nothing matched" and sends the reader looking for the wrong thing.
          <span id={errorId} className="text-destructive">
            Incomplete pattern — showing everything until it is valid
          </span>
        ) : (
          `${String(showing)} of ${String(total)} lines`
        )}
      </p>
    </div>
  );
}