Developer

Log viewer

A scrolling list of log lines with levels that follows the newest line and filters by level and text.

Built on
content-visibilityResizeObserver@starting-style
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.4 kB
Built from
BadgeButtonToggle group
  1. 12:04:02InfoCloning
  2. 12:04:05InfoInstalling
  3. 12:04:31WarnCache miss
  4. 12:05:12InfoBuilt 41s
  5. 12:05:20ErrorProbe 502
Code
import { LogViewer } from "@/components/equip/log-viewer";
import type { LogLine } from "@/components/equip/log-viewer";

const lines: LogLine[] = [
  { id: "1", time: "12:04:02", level: "info", message: "Cloning" },
  { id: "2", time: "12:04:05", level: "info", message: "Installing" },
  { id: "3", time: "12:04:31", level: "warn", message: "Cache miss" },
  { id: "4", time: "12:05:12", level: "info", message: "Built 41s" },
  { id: "5", time: "12:05:20", level: "error", message: "Probe 502" },
];

export default function Demo() {
  return <LogViewer label="Deploy log" lines={lines} toolbar={false} maxHeight={120} style={{ width: 224, maxWidth: "100%" }} />;
}

Examples

Level chips and text filter

Each chip counts every line at that level, and matches for the filter text are marked in place.

  1. 09:12:03.981InfoGET /v1/deploys 200 in 84ms
  2. 09:12:09.310WarnGET /v1/deploys/dpl_9x took 1.9s (slow query)
  3. 09:12:11.774InfoPOST /v1/deploys 201 in 212ms
  4. 09:12:15.052ErrorPOST /v1/deploys 500: connection reset by peer
  5. 09:12:15.060InfoRetrying POST /v1/deploys (attempt 2 of 3)
Code
import { LogViewer } from "@/components/equip/log-viewer";
import type { LogLine } from "@/components/equip/log-viewer";

const lines: LogLine[] = [
  { id: "1", time: "09:12:00.114", level: "debug", message: "Loaded config from equip.config.ts" },
  { id: "2", time: "09:12:00.420", level: "info", message: "Listening on :8080" },
  { id: "3", time: "09:12:03.981", level: "info", message: "GET /v1/deploys 200 in 84ms" },
  { id: "4", time: "09:12:04.002", level: "debug", message: "Cache hit for deploys:web" },
  { id: "5", time: "09:12:09.310", level: "warn", message: "GET /v1/deploys/dpl_9x took 1.9s (slow query)" },
  { id: "6", time: "09:12:11.774", level: "info", message: "POST /v1/deploys 201 in 212ms" },
  { id: "7", time: "09:12:15.052", level: "error", message: "POST /v1/deploys 500: connection reset by peer" },
  { id: "8", time: "09:12:15.060", level: "info", message: "Retrying POST /v1/deploys (attempt 2 of 3)" },
];

export default function Example() {
  return <LogViewer label="API server log" lines={lines} defaultQuery="deploys" defaultLevels={["info", "warn", "error"]} maxHeight={220} style={{ maxWidth: 560 }} />;
}

Following a live stream

Scroll up and following stops; the button counts what arrived since and takes you back down.

  1. 14:20:00InfoPulling image registry.example.com/web:7f3a9c
  2. 14:20:03InfoStarting 3 replicas in eu-west-1
  3. 14:20:06DebugReplica web-1 ready in 2.1s
  4. 14:20:09DebugReplica web-2 ready in 2.4s
Code
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { LogViewer } from "@/components/equip/log-viewer";
import type { LogLine } from "@/components/equip/log-viewer";

const STEPS: Omit<LogLine, "id" | "time">[] = [
  { level: "info", message: "Pulling image registry.example.com/web:7f3a9c" },
  { level: "info", message: "Starting 3 replicas in eu-west-1" },
  { level: "debug", message: "Replica web-1 ready in 2.1s" },
  { level: "debug", message: "Replica web-2 ready in 2.4s" },
  { level: "warn", message: "Replica web-3 slow to start, retrying probe" },
  { level: "info", message: "Replica web-3 ready in 6.8s" },
  { level: "info", message: "Shifting 10% of traffic" },
  { level: "info", message: "Shifting 50% of traffic" },
  { level: "info", message: "Shifting 100% of traffic" },
  { level: "info", message: "Deploy dpl_7f3a9c is live" },
];

function stamp(n: number) {
  return `14:20:${String(n * 3).padStart(2, "0")}`;
}

export default function Example() {
  const [count, setCount] = useState(4);
  const [running, setRunning] = useState(false);

  // Streams one line a second once started, as a deploy would.
  useEffect(() => {
    if (!running) return;
    const t = window.setInterval(() => setCount((c) => Math.min(c + 1, STEPS.length)), 1000);
    return () => window.clearInterval(t);
  }, [running]);

  useEffect(() => {
    if (count >= STEPS.length) setRunning(false);
  }, [count]);

  const lines = STEPS.slice(0, count).map((s, i) => ({ ...s, id: String(i), time: stamp(i) }));

  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 520 }}>
      <LogViewer label="Rollout log" lines={lines} maxHeight={120} toolbar={false} />
      <div>
        <Button
          variant="outline"
          size="s"
          disabled={running}
          onClick={() => {
            if (count >= STEPS.length) setCount(4);
            setRunning(true);
          }}
        >
          {count >= STEPS.length ? "Replay rollout" : "Start rollout"}
        </Button>
      </div>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the scroll region, such as Build log for web.
lines{ id: string; time: string; level: "debug" | "info" | "warn" | "error"; message: string }[]noneOldest first, each with a stable id. Append new lines to the end.
followbooleannoneControlled: whether the view sticks to the newest line.
defaultFollowbooleantrueUncontrolled starting value of follow.
onFollowChange(follow: boolean) => voidnoneCalled when following stops on scroll up or resumes.
levelsLogLevel[]noneControlled: the levels shown.
defaultLevelsLogLevel[]all fourUncontrolled starting levels.
onLevelsChange(levels: LogLevel[]) => voidnoneCalled when a level chip is toggled.
querystringnoneControlled: text a line must contain. Matches are marked.
defaultQuerystring""Uncontrolled starting filter text.
onQueryChange(query: string) => voidnoneCalled as the filter text changes.
toolbarbooleantrueShows the level chips and the filter field.
maxHeightnumber320Height in pixels before the lines scroll.
wrapbooleanfalseSoft-wraps long messages instead of scrolling sideways.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-log-viewer
toolbar.eq-log-viewer-toolbarA toggle group of levels and the filter field.
count.eq-log-viewer-countLines at that level, inside each chip.
search.eq-log-viewer-search
frame.eq-log-viewer-framePositions the jump button over the lines.
scroll.eq-log-viewer-scrollThe focusable, labelled region. Not live.
list.eq-log-viewer-list
row.eq-log-viewer-rowCarries data-level and skips rendering while off screen.
time.eq-log-viewer-time
level.eq-log-viewer-levelA badge with the level as a word.
message.eq-log-viewer-message
match.eq-log-viewer-matchA mark around text matching the filter.
empty.eq-log-viewer-empty
jump.eq-log-viewer-jumpJump to latest, with the count of new lines.
new count.eq-log-viewer-new
status.eq-log-viewer-srAnnounces the line count after a filter change only.

Keyboard

Arrow LeftArrow RightMove between level chips.
EnterSpaceToggle the focused level chip, or jump to the latest line.
EscapeClears the filter text.
Arrow UpPage UpHomeScroll the focused log back, which stops following.
EndScrolls to the newest line, which resumes following.

Notes

  • The region is not a live region: new lines are never announced, because a log is noisy.
  • Filtering announces how many lines are shown, and only when the reader changes a filter.
  • Levels are words in a badge as well as a tint, never colour alone.
  • Rows use content-visibility: auto, so a few thousand lines stay fast without virtualisation; tens of thousands want a windowed list.
  • Without wrap, the sideways scroll width follows the longest line currently rendered.
  • Matching is a plain case-insensitive substring, not a pattern.
  • Where content-visibility is unsupported every row renders, which is correct but slower for very long logs. The jump button arrives without animation where @starting-style is unsupported.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/log-viewer.tsx269 lines
"use client";

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import { Badge } from "./badge";
import { Button } from "./button";
import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
import "./log-viewer.css";

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

export type LogLine = {
  id: string;
  /** Shown as given, such as "12:04:31.208". */
  time: string;
  level: LogLevel;
  message: string;
};

export type LogViewerProps = Omit<ComponentProps<"div">, "children"> & {
  /** Names the scroll region, such as "Build log for web". */
  label: string;
  /** Oldest first. Append to the end; give each line a stable id. */
  lines: LogLine[];
  /** Controlled: whether the view sticks to the newest line. */
  follow?: boolean;
  defaultFollow?: boolean;
  /** Called when following stops (the reader scrolled up) or resumes. */
  onFollowChange?: (follow: boolean) => void;
  /** Controlled: the levels shown. */
  levels?: LogLevel[];
  defaultLevels?: LogLevel[];
  onLevelsChange?: (levels: LogLevel[]) => void;
  /** Controlled: text that lines must contain. Matches are marked. */
  query?: string;
  defaultQuery?: string;
  onQueryChange?: (query: string) => void;
  /** Shows the level chips and the filter field. */
  toolbar?: boolean;
  /** Height in pixels before the lines scroll. */
  maxHeight?: number;
  /** Soft-wraps long messages instead of scrolling sideways. */
  wrap?: boolean;
};

const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
const NAMES: Record<LogLevel, string> = { debug: "Debug", info: "Info", warn: "Warn", error: "Error" };
const TONES = { debug: "neutral", info: "neutral", warn: "warn", error: "danger" } as const;
/** Pixels from the bottom that still count as "at the newest line". */
const SLACK = 8;

function matches(line: LogLine, levels: LogLevel[], needle: string) {
  return levels.includes(line.level) && (!needle || line.message.toLowerCase().includes(needle));
}

function mark(text: string, needle: string): ReactNode {
  if (!needle) return text;
  const lower = text.toLowerCase();
  const parts: ReactNode[] = [];
  let from = 0;
  for (let at = lower.indexOf(needle); at >= 0; at = lower.indexOf(needle, from)) {
    if (at > from) parts.push(text.slice(from, at));
    parts.push(
      <mark key={at} className="eq-log-viewer-match">
        {text.slice(at, at + needle.length)}
      </mark>,
    );
    from = at + needle.length;
  }
  parts.push(text.slice(from));
  return parts;
}

export function LogViewer({
  label,
  lines,
  follow,
  defaultFollow = true,
  onFollowChange,
  levels,
  defaultLevels = LEVELS,
  onLevelsChange,
  query,
  defaultQuery = "",
  onQueryChange,
  toolbar = true,
  maxHeight = 320,
  wrap = false,
  className,
  style,
  ...rest
}: LogViewerProps) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLOListElement>(null);
  const lastTop = useRef(0);
  const [innerFollow, setInnerFollow] = useState(defaultFollow);
  const [innerLevels, setInnerLevels] = useState(defaultLevels);
  const [innerQuery, setInnerQuery] = useState(defaultQuery);
  /** The newest line when following stopped; everything after it is "new". */
  const [anchor, setAnchor] = useState<string | null>(null);
  const [announce, setAnnounce] = useState("");

  const following = follow ?? innerFollow;
  const shown = levels ?? innerLevels;
  const text = query ?? innerQuery;
  const needle = text.trim().toLowerCase();
  const followRef = useRef(following);

  const { visible, unseen, counts } = useMemo(() => {
    const counts: Record<LogLevel, number> = { debug: 0, info: 0, warn: 0, error: 0 };
    const visible: LogLine[] = [];
    let unseen = 0;
    let past = anchor === null;
    for (const line of lines) {
      counts[line.level] += 1;
      const hit = matches(line, shown, needle);
      if (hit) visible.push(line);
      if (past && hit && anchor !== null) unseen += 1;
      if (line.id === anchor) past = true;
    }
    return { visible, unseen, counts };
  }, [lines, shown, needle, anchor]);

  function setFollowing(next: boolean) {
    if (next === following) return;
    setInnerFollow(next);
    setAnchor(next ? null : (lines[lines.length - 1]?.id ?? null));
    onFollowChange?.(next);
  }

  function pin() {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
    lastTop.current = el.scrollTop;
  }

  useLayoutEffect(() => {
    followRef.current = following;
    if (following) pin();
  }, [following, visible]);

  // Rows settle to their real height as they render; keep the newest one in view while following.
  useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const observer = new ResizeObserver(() => {
      if (followRef.current) pin();
    });
    observer.observe(list);
    return () => observer.disconnect();
  }, []);

  function handleScroll() {
    const el = scrollRef.current;
    if (!el) return;
    const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
    const movedUp = el.scrollTop < lastTop.current - 1;
    lastTop.current = el.scrollTop;
    if (following && movedUp && gap > SLACK) setFollowing(false);
    else if (!following && gap <= SLACK) setFollowing(true);
  }

  function say(nextLevels: LogLevel[], nextNeedle: string) {
    const n = lines.filter((l) => matches(l, nextLevels, nextNeedle)).length;
    setAnnounce(n === lines.length ? `Showing all ${n} lines` : `Showing ${n} of ${lines.length} lines`);
  }

  function changeLevels(value: string[]) {
    const next = LEVELS.filter((l) => value.includes(l));
    setInnerLevels(next);
    onLevelsChange?.(next);
    say(next, needle);
  }

  function changeQuery(value: string) {
    setInnerQuery(value);
    onQueryChange?.(value);
    say(shown, value.trim().toLowerCase());
  }

  function jump() {
    scrollRef.current?.focus({ preventScroll: true });
    setFollowing(true);
  }

  const present = LEVELS.filter((l) => counts[l] > 0 || !shown.includes(l));

  return (
    <div
      {...rest}
      className={["eq-log-viewer", className].filter(Boolean).join(" ")}
      style={{ "--_max": `${maxHeight}px`, ...style } as CSSProperties}
      data-wrap={wrap || undefined}
    >
      {toolbar && (
        <div className="eq-log-viewer-toolbar">
          {present.length > 0 && (
            <ToggleGroup aria-label="Levels" value={shown} onValueChange={changeLevels}>
              {present.map((level) => (
                <ToggleGroupItem key={level} value={level}>
                  {NAMES[level]}
                  <span className="eq-log-viewer-count">{counts[level]}</span>
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          )}
          <input
            type="search"
            className="eq-log-viewer-search"
            placeholder="Filter lines"
            aria-label="Filter lines"
            spellCheck={false}
            value={text}
            onChange={(e) => changeQuery(e.currentTarget.value)}
            onKeyDown={(e) => {
              if (e.key === "Escape" && text) {
                e.preventDefault();
                e.stopPropagation();
                changeQuery("");
              }
            }}
          />
        </div>
      )}
      <div className="eq-log-viewer-frame">
        <div
          ref={scrollRef}
          className="eq-log-viewer-scroll"
          role="region"
          aria-label={label}
          tabIndex={0}
          onScroll={handleScroll}
        >
          <ol ref={listRef} className="eq-log-viewer-list">
            {visible.map((line) => (
              <li key={line.id} className="eq-log-viewer-row" data-level={line.level}>
                <span className="eq-log-viewer-time">{line.time}</span>
                <Badge className="eq-log-viewer-level" tone={TONES[line.level]}>
                  {NAMES[line.level]}
                </Badge>
                <span className="eq-log-viewer-message">{mark(line.message, needle)}</span>
              </li>
            ))}
          </ol>
          {visible.length === 0 && (
            <p className="eq-log-viewer-empty">{lines.length === 0 ? "No lines yet" : "No lines match the filter"}</p>
          )}
        </div>
        <Button
          size="s"
          className="eq-log-viewer-jump"
          hidden={following}
          onClick={jump}
          trail={
            unseen > 0 ? (
              <span className="eq-log-viewer-new">{unseen} new</span>
            ) : undefined
          }
        >
          Jump to latest
        </Button>
      </div>
      <span className="eq-log-viewer-sr" role="status">
        {announce}
      </span>
    </div>
  );
}
components/equip/log-viewer.css211 lines
@layer equip {
  .eq-log-viewer {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    min-width: 0;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
    color: var(--eq-ink);
    overflow: hidden;
  }

  .eq-log-viewer-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-2);
    padding: var(--eq-space-2);
    border-bottom: 1px solid var(--eq-line);
  }

  /* The chips wrap onto a second line rather than being cut off by the frame. */
  .eq-log-viewer-toolbar > .eq-toggle-group {
    flex-wrap: wrap;
    max-width: 100%;
    height: auto;
    min-height: var(--_h);
  }

  /* Set apart by weight, not by fading, so the count keeps full contrast. */
  .eq-log-viewer-count {
    font-variant-numeric: tabular-nums;
    font-weight: var(--eq-weight);
  }

  .eq-log-viewer-search {
    box-sizing: border-box;
    flex: 1 1 140px;
    min-width: 0;
    height: var(--eq-h-m);
    margin: 0;
    padding-inline: var(--eq-space-3);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-log-viewer-search::placeholder {
    color: var(--eq-ink-3);
  }

  .eq-log-viewer-search:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-log-viewer-frame {
    position: relative;
    min-width: 0;
  }

  .eq-log-viewer-scroll {
    max-height: var(--_max);
    overflow: auto;
    overscroll-behavior: contain;
  }

  /* Inset, because the frame clips anything drawn outside it. */
  .eq-log-viewer-scroll:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  /* As wide as the longest rendered line, so row tints span the scroll width. */
  .eq-log-viewer-list {
    width: max-content;
    min-width: 100%;
    margin: 0;
    padding: var(--eq-space-1) 0;
    list-style: none;
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight);
    line-height: 1.6;
  }

  .eq-log-viewer-list:empty {
    display: none;
  }

  .eq-log-viewer[data-wrap] .eq-log-viewer-list {
    width: auto;
  }

  /* Rows off screen skip layout and paint, which keeps thousands of lines cheap. */
  .eq-log-viewer-row {
    box-sizing: border-box;
    display: grid;
    grid-template-columns: auto auto minmax(0, 1fr);
    align-items: start;
    gap: var(--eq-space-2);
    padding: 2px var(--eq-space-3);
    content-visibility: auto;
    contain-intrinsic-block-size: auto 24px;
  }

  .eq-log-viewer-row[data-level="warn"] {
    background: color-mix(in srgb, var(--eq-warn) 9%, transparent);
  }

  .eq-log-viewer-row[data-level="error"] {
    background: color-mix(in srgb, var(--eq-danger) 9%, transparent);
  }

  .eq-log-viewer-time {
    color: var(--eq-ink-3);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  /* Equal widths keep every message starting in the same column. */
  .eq-log-viewer-row > .eq-log-viewer-level {
    justify-content: center;
    min-width: 46px;
  }

  .eq-log-viewer-message {
    white-space: pre;
  }

  .eq-log-viewer[data-wrap] .eq-log-viewer-message {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }

  .eq-log-viewer-match {
    border-radius: 2px;
    background: color-mix(in srgb, var(--eq-warn) 32%, transparent);
    color: inherit;
  }

  .eq-log-viewer-empty {
    margin: 0;
    padding: var(--eq-space-5) var(--eq-space-4);
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
    text-align: center;
  }

  /* Floats over the newest lines; arrives with the spring, leaves faster. */
  .eq-log-viewer-frame > .eq-log-viewer-jump {
    position: absolute;
    inset-inline: 0;
    inset-block-end: var(--eq-space-3);
    width: fit-content;
    margin-inline: auto;
    box-shadow: var(--eq-shadow);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      opacity var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring),
      scale var(--eq-dur) var(--eq-ease),
      display var(--eq-dur-move) allow-discrete;
  }

  @starting-style {
    .eq-log-viewer-frame > .eq-log-viewer-jump {
      opacity: 0;
      translate: 0 4px;
      scale: 0.96;
    }
  }

  .eq-log-viewer-frame > .eq-log-viewer-jump[hidden] {
    display: none;
    opacity: 0;
    translate: 0 4px;
    scale: 0.96;
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      translate var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease),
      display var(--eq-dur) allow-discrete;
  }

  .eq-log-viewer-new {
    padding-inline-start: var(--eq-space-2);
    border-inline-start: 1px solid color-mix(in srgb, currentColor 35%, transparent);
    font-variant-numeric: tabular-nums;
    font-weight: var(--eq-weight);
  }

  /* Read by screen readers, never seen. */
  .eq-log-viewer-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index