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
- 12:04:02Info
- 12:04:05Info
- 12:04:31Warn
- 12:05:12Info
- 12:05:20Error
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.
- 09:12:03.981Info
- 09:12:09.310Warn
- 09:12:11.774Info
- 09:12:15.052Error
- 09:12:15.060Info
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.
- 14:20:00Info
- 14:20:03Info
- 14:20:06Debug
- 14:20:09Debug
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
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the scroll region, such as Build log for web. |
lines | { id: string; time: string; level: "debug" | "info" | "warn" | "error"; message: string }[] | none | Oldest first, each with a stable id. Append new lines to the end. |
follow | boolean | none | Controlled: whether the view sticks to the newest line. |
defaultFollow | boolean | true | Uncontrolled starting value of follow. |
onFollowChange | (follow: boolean) => void | none | Called when following stops on scroll up or resumes. |
levels | LogLevel[] | none | Controlled: the levels shown. |
defaultLevels | LogLevel[] | all four | Uncontrolled starting levels. |
onLevelsChange | (levels: LogLevel[]) => void | none | Called when a level chip is toggled. |
query | string | none | Controlled: text a line must contain. Matches are marked. |
defaultQuery | string | "" | Uncontrolled starting filter text. |
onQueryChange | (query: string) => void | none | Called as the filter text changes. |
toolbar | boolean | true | Shows the level chips and the filter field. |
maxHeight | number | 320 | Height in pixels before the lines scroll. |
wrap | boolean | false | Soft-wraps long messages instead of scrolling sideways. |
...rest | ComponentProps<"div"> | none | Every native div attribute. |
Anatomy
| root | .eq-log-viewer | |
|---|---|---|
| toolbar | .eq-log-viewer-toolbar | A toggle group of levels and the filter field. |
| count | .eq-log-viewer-count | Lines at that level, inside each chip. |
| search | .eq-log-viewer-search | |
| frame | .eq-log-viewer-frame | Positions the jump button over the lines. |
| scroll | .eq-log-viewer-scroll | The focusable, labelled region. Not live. |
| list | .eq-log-viewer-list | |
| row | .eq-log-viewer-row | Carries data-level and skips rendering while off screen. |
| time | .eq-log-viewer-time | |
| level | .eq-log-viewer-level | A badge with the level as a word. |
| message | .eq-log-viewer-message | |
| match | .eq-log-viewer-match | A mark around text matching the filter. |
| empty | .eq-log-viewer-empty | |
| jump | .eq-log-viewer-jump | Jump to latest, with the count of new lines. |
| new count | .eq-log-viewer-new | |
| status | .eq-log-viewer-sr | Announces the line count after a filter change only. |
Keyboard
| Arrow LeftArrow Right | Move between level chips. |
|---|---|
| EnterSpace | Toggle the focused level chip, or jump to the latest line. |
| Escape | Clears the filter text. |
| Arrow UpPage UpHome | Scroll the focused log back, which stops following. |
| End | Scrolls 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;
}
}