Data Display
LogViewer
A log console: levels, filtering, search, ANSI colour, expandable rows and a tail that follows.
import { LogViewer } from 'omaris' 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.
<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.
<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.
<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.
<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.
followsticks 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,sourceanddetails. 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.
searchnarrows and marks, Enter steps through the hits.levelsis bindable, so the chips are state the page can read.
Not for
- A finished file nobody filters → Code Block with
lang="bash"andcollapse. - Events with a time and a title, one per row → List.
- One payload someone explores → JSON Viewer.
- Metrics over time → Chart.
Do
- Append to
linesrather than replacing it. A new line parses one line. - Pass entries, not strings, when you have the parts. A
sourcetag and a realtimemake theerrorchips and the timestamp column useful. - Give it a
heightand let it scroll inside. A console the page scrolls around loses the tail. - Set
filenamewhentoolsis on, so the download isdeploy-42.logand notlog.txt.
Don't
- Default
wrapon 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
followon. 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--warningand--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.
matchCount3 / 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.
sourceapi,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 bindableLogLevel[] 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 bindableDefaults 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 bindableDefaults 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 bindableDefaults 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