Log Viewer
Explore large logs with variable-height virtual scrolling and sticky tabular attributes.
Read thousands of log entries without rendering thousands of rows. c2-log-viewer combines virtual scrolling for variable-height text with sticky attributes that keep a long entry’s context visible. Single-line messages, wrapped output and multiline traces can share the same viewport.
Import
import '@c2n/components/log-viewer'Usage
These examples model a server access stream and a commercial checkout incident. The log viewers use their default CSS values; the surrounding title bars and navigation buttons belong to the application.
Try live streaming: the first example receives 1,000 entries over five minutes, like a running service. It stays on the latest entry as they arrive; scroll up to read and new entries keep your place, then press Latest entry (or End) to follow again.
Try virtual scrolling: the second example contains 10,000 entries, including messages with different heights. Scroll through the stream, or jump between the beginning and the latest entry.
Try sticky attributes: in the third example, scroll slowly through the long error trace. Its timestamp and ERROR label stay at the top while the message moves. They leave with that entry when the next one arrives.
Readable log colors
Message text is colored automatically in both layouts: ERROR/FATAL in rose, WARN/WARNING in amber, INFO in green, and DEBUG/TRACE and timestamps in muted gray. Quoted values, numbers, URLs and recognizable IDs use soft blue. Ordinary text keeps the neutral foreground. Colors do not change wrapping or row height, and filtering highlights remain a separate background treatment.
Token colors use the existing severity and timestamp CSS variables, plus --c2-log-viewer__token--color for values. Logical lines touched by the virtual window are tokenized lazily, preserving colors across wrapped fragments and visible slices; this is lightweight log recognition rather than programming-language syntax highlighting. Copy always returns the original message without formatting.
Copy a message
Move the pointer over an entry to reveal its copy icon. Scrolling hides pointer actions until the pointer moves again, preventing buttons from jumping between messages under a stationary cursor. The icon has a transparent surface; only hovering the icon adds a background. Keyboard users can Tab to the same action; touch devices show it directly. Copy includes the complete original message, even when only part of a long trace is currently rendered. Timestamp and other attributes are excluded. --c2-log-viewer__copy--icon-size, --c2-log-viewer__copy--stroke-width, and the copy-icon part style both the copy and success icons. A check mark and accessible status confirm success; clipboard failures are announced so you can retry.
Virtual scrolling
The viewer renders the entries in the visible window plus a small overscan margin. A cumulative height index finds the right window as you scroll. Rows do not need a fixed height: line breaks and measured wrapping determine each entry’s size.
In continuous text mode (tabular="false"), it also renders only the visible lines inside a very large entry. A single long trace can be read without mounting all of its text.
Appends follow the end when you are already there. Scroll up to read history and incoming entries preserve your position. Call scrollToEnd() to resume following, or scrollToStart() to open at the first entry: an analysis view that replaces its buffer (clear() then appendEntries()) calls it afterwards, and the viewer stays at the top through later appends until the reader scrolls.
Streaming
Pass appendEntries() only the new entries. The viewer keeps what it already has and lays out just the new rows while column widths stay the same, so a long-running stream costs the same per entry at 100 or 10,000 lines. When a new attribute value widens a column, an unwrapped log only updates its scroll width, but a wrapped one (wrap) lays out its retained rows again for the narrower message column. Calls made in quick succession render together, at most once per 16ms during a burst. Avoid replacing the whole log on every update (clear() followed by appendEntries(allEntries)): each update then lays out every entry again.
// React: `logs` grows by appending ([...logs, entry]). Keep how much the viewer already has and append the rest.
const appended = useRef({ first: undefined as LogEntry | undefined, count: 0 })
useEffect(() => {
const viewer = viewerRef.current
if (!viewer) return
const handed = appended.current
// A different first entry (or a shorter log) means the application started a new log, not a longer one.
if (logs[0] !== handed.first || logs.length < handed.count) {
viewer.clear()
handed.count = 0
}
viewer.appendEntries(logs.slice(handed.count))
appended.current = { first: logs[0], count: logs.length }
}, [logs])Sticky attributes
Tabular mode is enabled by default. Attributes align vertically in columns, and each entry is as tall as its longest attribute. Shorter attributes stick to the top of the viewport within that entry. When the entry ends, its context scrolls away and the next entry takes over.
There is no separate sticky switch: it is part of tabular mode. Use columns to choose the attributes and their order; set tabular = false for continuous text. wrap controls long-line wrapping in either mode.
The title bars and buttons above belong to the example application. The log viewer itself remains a content viewport, controlled through its public API.
Connect your data
import '@c2n/components/log-viewer'
const viewer = document.querySelector('c2-log-viewer')!
viewer.columns = ['timestamp', 'level', 'message']
viewer.appendEntries([
{ timestamp: '14:08:18', level: 'INFO', orderId: 'ORD-10428', message: 'Checkout started · EUR 129.90' },
{ timestamp: '14:08:32', level: 'ERROR', orderId: 'ORD-10428', message: 'Payment timeout\
Retry scheduled · cart preserved' },
])
// Call from an application search field, filter panel, or another component.
viewer.setFilter({ attributes: { level: ['ERROR', 'WARN'], orderId: 'ORD-10428' }, search: 'retry' })
viewer.setFilter(null) // Restore every retained entry.
viewer.wrap = true
viewer.tabular = false
viewer.scrollToEnd()Choose an entry
Clicking an entry fires entry-click with the entry and its index among the retained entries (the same position whatever the filter). Use it to open a detail panel or add the entry’s attributes to a filter. Clicks on the copy button, and the click that ends a drag or text selection, are not reported.
The keyboard reaches the same entries. The viewport keeps an active entry: Up/Down move it one entry at a time, PageUp/PageDown by a viewport, Home/End to the first and last, and Enter fires entry-activate with the same detail as entry-click. The first key starts from the entry at the top of the view, so focusing a log you have scrolled never jumps away from it. An entry taller than the view is read line by line before the next one becomes active. A click makes its entry active too, and activeIndex reads or moves it from code.
const open = (event: CustomEvent<LogViewerEntryEventDetail>) => openDetails(event.detail.entry, event.detail.index)
viewer.addEventListener('entry-click', open)
viewer.addEventListener('entry-activate', open)Find in the log
The viewer renders only the entries near the view, so the browser’s own find cannot see the rest. Press Ctrl/Command+F with focus in the viewer to open its find bar instead: it searches every displayed entry, marks the matches, moves the active entry to the current one and shows “3 of 128”. Enter and Shift+Enter (or F3, Ctrl/Command+G) step through the matches and wrap at the ends; Alt+C toggles match case; Escape closes the bar and returns focus to the log, on the last match. Text selected in the viewer becomes the query. Pressing Ctrl/Command+F again inside the bar is left to the browser.
Find is plain text, case-insensitive unless match case is on, across the columns on screen. It is the viewer’s own and independent of setFilter: it searches what the filter displays, and re-runs when the filter or the entries change. The bar is built from c2-search-field and c2-icon-button, loaded the first time the viewer is focused; --c2-log-viewer__find-* variables theme it, and a ruler along the right edge shows where the matches are.
await viewer.find('timeout') // open the bar on a query
viewer.findNext() // or findPrevious()
console.log(viewer.findMatchIndex, viewer.findMatchCount)
viewer.closeFind()Set find-shortcut="false" when the page has its own Ctrl/Command+F; the methods still work.
Filter or highlight
Use the same criteria in two modes:
const criteria = { attributes: { level: ['WARN', 'ERROR'] } }
viewer.setFilter(criteria) // Default: display only matching entries.
viewer.setFilter(criteria, 'highlight') // Keep all entries, mark the matches.
viewer.setFilter(null) // Clear criteria and highlights.Highlight mode preserves the surrounding context and the entry you are reading. It works in tabular and continuous text modes, including with virtual scrolling and future appends. filteredCount always reports the number of matches, even when all entries remain visible.
Data and filtering
Every entry requires a string message and accepts arbitrary string attributes. Batches are validated before anything is appended. Entries and filter criteria are copied, so changing the input objects afterwards does not affect the viewer.
columns specifies the attribute order and displayed attributes. When empty, attributes are discovered automatically: timestamp, level and source first, additional attributes next, and message last. The same order is used to concatenate attributes with spaces in plain mode; entries are separated by newlines. Explicit line breaks and empty lines are retained. Tabs display as four spaces.
setFilter(criteria, mode) replaces the active criteria and display mode (filter by default, or highlight). Attribute values match exactly and are case sensitive. Arrays mean any of those values; multiple attributes must all match. search is a case insensitive substring search across all attributes, including hidden columns, combined with the attribute criteria. A missing attribute does not match an exact value. null clears the filter. Stored entries remain available, and future appends are evaluated against the active criteria.
entryCount includes every retained entry; filteredCount reports matching entries after updateComplete. clear() removes entries while retaining the current filter. The application owns fetching, transport, retention, controls and statistics.
Layout and accessibility
Rows use a cumulative height index and binary viewport lookup. Wrapped text uses measured glyph widths, so rows do not need a common height. Resize, wrap changes and font changes recalculate layout while preserving the top entry being read. Appends follow the end when the viewport is already there; otherwise they preserve the reading position. scrollToEnd() resumes following, and scrollToStart() (or Home) returns to the first entry.
Each attribute column is automatically sized to its widest value, including cell padding. Only the message column takes the remaining viewport width, with --c2-log-viewer__message--min-width as its minimum. Widths are measured across all retained entries, so filtering keeps the columns aligned. With wrap disabled, long messages scroll horizontally without changing attribute column widths. In narrow viewports, columns remain aligned and can scroll horizontally. Cell padding, plain text inset and empty state padding are also exposed as CSS variables and included in layout measurement.
The viewport is one Tab stop: a named tree grid (aria-rowcount, aria-rowindex on each rendered row) whose active entry is announced through aria-activedescendant, so focus never leaves the scroll area. Home jumps to the first entry; End jumps to the latest and resumes following appends. Ctrl/Command with Home or End also works. Movement is immediate, and Left/Right retain native horizontal scrolling. Find announces “Match 3 of 128” through a live region. Selection across unrendered content and printing only see the rendered window; use application data for complete exports.