Skip to content
omaris

Data Display

LogViewer

A log console: levels, filtering, search, ANSI colour, expandable rows and a tail that follows.

import { LogViewer } from 'omaris'
Learn

Examples

Basic

Entries carry their own level, time and source. Click a row that has details to open them under it — an object as a JSON tree, a string (a stack trace, say) as a block. Rows stay virtualised while panels are open, so the same console handles ten lines or a hundred thousand.

9
09:14:02 AM info api server listening on :3000
09:14:05 AM info api GET /api/orders 200 in 24ms
09:14:08 AM info api POST /api/orders 201 in 96ms
09:14:11 AM info payments retrying payment intent pi_8f21 (1/3)
09:14:15 AM trace cache cache write orders:8f21 (ttl 60s)
<script lang="ts">	import { LogViewer, type LogEntry } from 'omaris';​	const start = new Date('2026-09-04T09:14:02');	const at = (s: number) => new Date(start.getTime() + s * 1000);​	const LINES: LogEntry[] = [		{ level: 'info', time: at(0), source: 'api', message: 'server listening on :3000' },		{			level: 'debug',			time: at(1),			source: 'db',			message: 'pool: 4 connections opened',			details: { host: 'db.internal', pool: { min: 2, max: 8, idle_ms: 30000 } }		},		{ level: 'info', time: at(3), source: 'api', message: 'GET /api/orders 200 in 24ms' },		{			level: 'warn',			time: at(5),			source: 'db',			message: 'slow query: orders.by_customer took 812ms',			details: {				query: 'orders.by_customer',				duration_ms: 812,				params: { customer: 'cus_41', limit: 50 },				plan: ['Seq Scan on orders', 'Filter: (customer_id = $1)']			}		},		{ level: 'info', time: at(6), source: 'api', message: 'POST /api/orders 201 in 96ms' },		{			level: 'error',			time: at(8),			source: 'payments',			message: 'payment gateway timed out after 5000ms',			details:				'GatewayTimeout: no response from https://gateway.example/v1/intents\n    at request (/app/lib/gateway.js:52:11)\n    at async createIntent (/app/lib/payments.js:118:15)\n    at async handler (/app/routes/orders.js:41:9)'		},		{			level: 'info',			time: at(9),			source: 'payments',			message: 'retrying payment intent pi_8f21 (1/3)'		},		{			level: 'info',			time: at(12),			source: 'payments',			message: 'payment intent pi_8f21 succeeded',			details: { intent: 'pi_8f21', amount: 24500, currency: 'IQD', attempts: 2 }		},		{ level: 'trace', time: at(13), source: 'cache', message: 'cache write orders:8f21 (ttl 60s)' }	];</script>​<LogViewer lines={LINES} height="18rem" class="w-full" />

Following a tail

follow sticks to the newest line while output arrives and lets go the moment you scroll up — the behaviour every terminal has. Scroll away and the "jump to newest" button appears; levels is bindable, so the chips are state you can read. Appending is incremental: a new line parses one line.

0 lines · following: yes · 6 levels shown
0

Nothing yet — press start.

<script lang="ts">	import { Button, LogViewer, Text, type LogEntry, type LogLevel } from 'omaris';​	const MESSAGES: [LogLevel, string, string][] = [		['info', 'api', 'GET /api/menu 200 in 12ms'],		['debug', 'cache', 'cache hit menu:erbil'],		['info', 'api', 'POST /api/orders 201 in 88ms'],		['warn', 'api', 'rate limit at 80% for 10.0.0.4'],		['error', 'receipts', 'upstream 502 from receipts-service'],		['info', 'worker-1', 'worker drained 12 jobs']	];​	let lines = $state<LogEntry[]>([]);	let follow = $state(true);	let levels = $state<LogLevel[]>(['trace', 'debug', 'info', 'warn', 'error', 'fatal']);	let timer: ReturnType<typeof setInterval> | null = null;​	function tick() {		const [level, source, message] = MESSAGES[Math.floor(Math.random() * MESSAGES.length)];		const details = level === 'error' ? { upstream: 'receipts-service', status: 502 } : undefined;		lines = [...lines, { level, source, time: new Date(), message, details }].slice(-500);	}​	function toggle() {		if (timer) {			clearInterval(timer);			timer = null;		} else {			timer = setInterval(tick, 600);		}	}​	$effect(() => () => {		if (timer) clearInterval(timer);	});</script>​<div class="flex w-full flex-col gap-3">	<div class="flex items-center gap-3">		<Button size="sm" variant="tonal" onclick={toggle}>Start / stop output</Button>		<Text variant="label-sm" tone="muted">			{lines.length} lines · following: {follow ? 'yes' : 'no'} · {levels.length} levels shown		</Text>	</div>​	<LogViewer		{lines}		bind:follow		bind:levels		height="14rem"		emptyText="Nothing yet — press start."	/></div>

Search and levels

Three hundred lines, mixed. Search narrows to the matching lines and marks each hit; Enter and Shift+Enter in the field (or the arrows beside it) step through them, and the current one scrolls into view. The chips carry counts, and a line with no level — the separators here — stays put whichever chips are off. ↑/↓ walk the rows, Enter opens one.

300
--- batch 1 ---
09:00:01 AM info api GET /api/menu 200 in 5ms
09:00:02 AM info worker-1 POST /api/receipts 200 in 6ms
09:00:03 AM debug api cache hit th:3
09:00:04 AM info worker-1 POST /api/orders 200 in 8ms
09:00:05 AM info api GET /api/menu 200 in 9ms
09:00:06 AM debug worker-1 cache miss receipts:6
09:00:07 AM warn api GET /health 429 in 11ms
09:00:08 AM info worker-1 POST /api/orders 200 in 12ms
<script lang="ts">	import { LogViewer, type LogEntry } from 'omaris';​	const ROUTES = ['/api/orders', '/api/menu', '/api/receipts', '/health'];	const start = new Date('2026-09-04T09:00:00');​	const LINES: (string | LogEntry)[] = Array.from({ length: 300 }, (_, i) => {		if (i % 25 === 0) return `--- batch ${i / 25 + 1} ---`;		const level = i % 17 === 0 ? 'error' : i % 7 === 0 ? 'warn' : i % 3 === 0 ? 'debug' : 'info';		const route = ROUTES[i % ROUTES.length];		const verb = i % 2 ? 'GET' : 'POST';		return {			time: new Date(start.getTime() + i * 1000),			level,			source: i % 2 ? 'api' : 'worker-1',			message:				level === 'error'					? `upstream 502 from receipts-service on ${route}`					: level === 'debug'						? `cache ${i % 6 ? 'hit' : 'miss'} ${route.slice(5) || 'health'}:${i}`						: `${verb} ${route} ${level === 'warn' ? '429' : '200'} in ${(i % 90) + 4}ms`,			details:				level === 'error'					? { route, status: 502, upstream: 'receipts-service', request: i }					: undefined		};	});</script>​<LogViewer lines={LINES} height="20rem" size="sm" class="w-full" />

Overridden

Plain strings work too — a level is read off the line where there is one, and ANSI colour comes through as theme tokens. height lives in a property rather than a class, so a max-h-* on classes.viewport genuinely replaces it, and the header can be stripped back to nothing with search, filters and wrappable off. Every part is reachable through classes.

$ bun run build
vite v6.0.0 building SSR bundle for production...
✓ 412 modules transformed.
warn chunk "docs" is larger than 500 kB after minification
✓ built in 3.42s
error prerender failed for /docs/components/nope (404)
done in 4.10s
<script lang="ts">	import { LogViewer } from 'omaris';​	const E = '\u001b';	const OUTPUT = [		'$ bun run build',		'vite v6.0.0 building SSR bundle for production...',		`${E}[32m✓${E}[0m 412 modules transformed.`,		'WARN  chunk "docs" is larger than 500 kB after minification',		`${E}[32m✓${E}[0m built in ${E}[1m3.42s${E}[0m`,		'ERROR  prerender failed for /docs/components/nope (404)',		'done in 4.10s'	];</script>​<LogViewer	lines={OUTPUT}	size="sm"	search={false}	filters={false}	wrappable={false}	timestamps={false}	lineNumbers={false}	height="auto"	classes={{		root: 'rounded-shape-lg border-none bg-surface-container-high',		viewport: 'max-h-none py-2',		message: 'text-body-sm',		row: 'data-[level=error]:bg-transparent data-[level=error]:text-destructive'	}}	class="w-full"/>

When to use it

Use it for

  • Output that keeps arriving from a build, a deploy or a worker. follow sticks to the newest line and lets go the moment the reader scrolls up.
  • Plain strings from a process. The level is read off each line, ANSI colour resolves to theme tokens, and an existing log needs no preparation.
  • Structured entries with level, time, source and details. A row with details opens on click: an object as a JSON tree, a string as a block.
  • A hundred thousand lines. Rows are virtualised, so it scrolls as smoothly as ten and the scrollbar stays honest.
  • Finding one line. search narrows and marks, Enter steps through the hits. levels is bindable, so the chips are state the page can read.

Not for

  • A finished file nobody filters → Code Block with lang="bash" and collapse.
  • Events with a time and a title, one per row → List.
  • One payload someone explores → JSON Viewer.
  • Metrics over time → Chart.

Do

  • Append to lines rather than replacing it. A new line parses one line.
  • Pass entries, not strings, when you have the parts. A source tag and a real time make the error chips and the timestamp column useful.
  • Give it a height and let it scroll inside. A console the page scrolls around loses the tail.
  • Set filename when tools is on, so the download is deploy-42.log and not log.txt.

Don't

  • Default wrap on for a long log. Wrapping turns virtualisation off, because a wrapped row has no height until it is laid out.
  • Strip the header and keep follow on. The "jump to newest" button is how a reader who scrolled up gets back.
  • Colour lines by hand from classes.row. The level already uses the theme's --warning and --destructive, in both modes.

Quick reference

size
  • md (default)

API

LogViewer

A log console: levels, filtering, search, ANSI colour, expandable rows and a tail that follows.

The hard part of a log view is not the styling, it is that logs are long. Rows are virtualised — only the slice on screen exists in the DOM — so a hundred thousand lines scroll as smoothly as ten, and the scrollbar still tells the truth about how much there is. An expanded row's panel is the one thing whose height is measured rather than known, and there are never more than a few of those open, so the arithmetic stays a short walk over a sorted list. Wrapping turns virtualisation off deliberately: a wrapped line has no knowable height until it is laid out, and a guessed height is a jumping scrollbar.

Following the tail sticks to the bottom while output arrives and lets go the moment you scroll up, which is the behaviour every terminal has and most web log views forget.

import { LogViewer } from 'omaris'
<LogViewer {lines} follow search levels={['error', 'warn']} />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
header
No description in the source yet.
search
The search field — an Input, so this lands on its root.
searchNav
Match position and the previous / next buttons, beside the field.
matchCount
3 / 12.
filters
The level chips.
filter
One level chip — a Chip, so this lands on its root.
action
The wrap / follow / clear toggles.
tools
Copy-all and download, at the end of the header.
tool
A header icon button.
viewport
The scroll container.
canvas
Sized to the whole log, so the scrollbar is honest while rows are virtual.
window
The visible slice, pushed down to where it belongs.
row
One line.
toggle
The chevron in the gutter of a row that has details.
gutter
No description in the source yet.
time
No description in the source yet.
level
The level tag: fixed width, so messages line up.
source
api, worker-3 — where the line came from.
message
No description in the source yet.
mark
A search hit inside a line.
markActive
The hit on the row search is currently parked on.
rowAction
Copy-this-line, revealed on hover at the end of the row.
details
The panel under an expanded row.
detailsCard
Its card: sticky, so it stays in view while the log scrolls sideways.
detailsText
A string detail — a stack trace, a payload dump.
jump
"Jump to the newest line", while the tail is not being followed. A sibling of the viewport rather than a child: an absolute box inside a scroll container scrolls away with the log it is meant to float over.
empty
No description in the source yet.
count
The count, at the end of the header.
live
The screen-reader announcement of the newest line while following.

Props

lines required
(string | LogEntry)[]

Raw lines, one string each, or entries with their parts already split out.

size

Defaults to 'md'

LogViewerSize
md
levels bindable
LogLevel[]

Which levels to show. Left off, all of them. Lines with no level are always shown, unless this is an empty array. Bindable.

filters

Defaults to true

boolean

Show the level filter chips.

search

Defaults to true

boolean

Show the search box.

follow bindable

Defaults to false

boolean

Stick to the newest line as output arrives. Bindable.

wrappable

Defaults to true

boolean

Offer the wrap toggle in the header.

wrap bindable

Defaults to false

boolean

Wrap long lines instead of scrolling sideways. Bindable.

lineNumbers

Defaults to true

boolean

Line numbers down the left.

timestamps

Defaults to true

boolean

Draw the timestamp column.

tools

Defaults to true

boolean

Copy-all and download buttons in the header.

filename

Defaults to 'output.log'

string

Name of the file the download button saves.

expandable

Defaults to true

boolean

Rows with details open on click, Enter or Space.

expanded bindable

Defaults to []

number[]

Indices (into lines) of the rows whose details are open. Bindable.

onrowclick
(row: LogRow, index: number) => void

Called when a row is clicked or activated from the keyboard.

formatTime
(time: Date) => string

How a timestamp is written. Defaults to a 12-hour clock with seconds, 02:05:09 PM, in the page's language and Western digits. A bare 14:05:09 at the start of a raw line is read as today at that time.

emptyText

Defaults to 'Nothing to show.'

string

Shown when nothing is left after filtering.

height

Defaults to '24rem'

number | string

Height of the console. A number is px, a string any CSS length — a property rather than a class, so classes.viewport can replace it.

class
string
classes
LogViewerClasses