Log Stream
A live log tail: lines stream in, filter by level, search with highlight, pause to read, and jump back to the latest.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/log-stream.json'use client'
import { Fragment, useEffect, useRef, useState } from 'react'
import './log-stream.css'
export type Level = 'debug' | 'info' | 'warn' | 'error'
export interface LogLine { t: number; level: Level; msg: string }
export interface LogStreamProps {
source: (i: number) => Omit<LogLine, 't'>
interval?: number
max?: number
wrap?: boolean
fill?: 'dark' | 'light'
className?: string
}
const hl = (s: string, q: string): React.ReactNode => { if (!q) return s; const i = s.toLowerCase().indexOf(q.toLowerCase()); return i < 0 ? s : <>{s.slice(0, i)}<mark>{s.slice(i, i + q.length)}</mark>{hl(s.slice(i + q.length), q)}</> }
/** Live log tail with follow, pause, filter and search. */
export function LogStream({ source, interval = 700, max = 200, wrap = false, fill = 'dark', className }: LogStreamProps) {
const [lines, setLines] = useState<LogLine[]>([])
const [follow, setFollow] = useState(true)
const [missed, setMissed] = useState(0)
const [levels, setLevels] = useState<Level[]>(['info', 'warn', 'error'])
const [q, setQ] = useState('')
const body = useRef<HTMLDivElement>(null)
const n = useRef(0)
useEffect(() => {
const t0 = Date.now()
const i = setInterval(() => { const l = { ...source(n.current++), t: Date.now() - t0 }; setLines((x) => [...x.slice(-max + 1), l]); setMissed((m) => m + 1) }, interval)
return () => clearInterval(i)
}, [source, interval, max])
useEffect(() => { if (follow && body.current) { body.current.scrollTop = body.current.scrollHeight; setMissed(0) } }, [lines, follow, levels, q])
const shown = lines.filter((l) => levels.includes(l.level) && (!q || l.msg.toLowerCase().includes(q.toLowerCase())))
const ts = (ms: number) => { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}.${String(Math.floor((ms % 1000) / 10)).padStart(2, '0')}` }
return (
<div className={['sd-lgs', className].filter(Boolean).join(' ')} data-fill={fill} data-wrap={wrap || undefined}>
<div className="sd-lgs__bar">
<input className="sd-lgs__q" aria-label="Search logs" placeholder="Search" value={q} onChange={(e) => setQ(e.target.value)} />
{(['debug', 'warn', 'error'] as Level[]).map((l) => <button key={l} type="button" className="sd-lgs__lv" aria-pressed={levels.includes(l)} onClick={() => setLevels((x) => (x.includes(l) ? x.filter((y) => y !== l) : [...x, l]))}>{l === 'debug' ? 'DBG' : l === 'warn' ? 'WRN' : 'ERR'}</button>)}
<button type="button" className="sd-lgs__pp" aria-label={follow ? 'Pause following' : 'Follow new lines'} aria-pressed={!follow} onClick={() => setFollow(!follow)}>
<svg viewBox="0 0 12 12" aria-hidden>{follow ? <path d="M3 2h2v8H3zM7 2h2v8H7z" /> : <path d="M3 2v8l7-4z" />}</svg>
</button>
</div>
<div ref={body} className="sd-lgs__body" role="log" aria-live={follow ? 'polite' : 'off'} aria-label="Log output" tabIndex={0}
onScroll={(e) => { const el = e.currentTarget; const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 8; if (!atEnd && follow) setFollow(false); if (atEnd && !follow) setFollow(true) }}>
{shown.map((l, i) => (
<Fragment key={l.t + '-' + i}>
<div className="sd-lgs__l" data-lv={l.level}><span className="sd-lgs__t">{ts(l.t)}</span><span className="sd-lgs__v">{l.level === 'debug' ? 'DBG' : l.level.toUpperCase().slice(0, 4)}</span><span>{hl(l.msg, q)}</span></div>
</Fragment>
))}
</div>
{!follow && missed > 0 && <button type="button" className="sd-lgs__jump" onClick={() => setFollow(true)}>↓ {missed} new {missed === 1 ? 'line' : 'lines'}</button>}
</div>
)
}.sd-lgs { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lgs[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lgs :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lgs :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lgs .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lgs { position: relative; width: min(100%, 360px); border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-lgs__bar { display: flex; align-items: center; gap: 6px; padding: 8px; border-bottom: 1px solid var(--line); }
.sd-lgs__q { flex: 1; min-width: 0; height: 30px; padding: 0 10px; border: 0; border-radius: 8px; background: var(--soft); color: var(--fg); font: inherit; font-size: 12.5px; outline: 0; }
.sd-lgs__q:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-lgs__lv { height: 30px; padding: 0 8px; border-radius: 8px; font: 600 11px/1 ui-monospace, monospace; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-lgs__lv[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-lgs__pp { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-lgs__pp svg { width: 12px; height: 12px; fill: currentColor; }
.sd-lgs__body { height: 320px; overflow-y: auto; padding: 6px 0; font: 11.5px/1.65 ui-monospace, SFMono-Regular, monospace; overscroll-behavior: contain; }
.sd-lgs__l { display: grid; grid-template-columns: 58px 40px 1fr; gap: 6px; padding: 0 10px; white-space: nowrap; animation: sd-lgs-in 200ms ease-out; }
.sd-lgs[data-wrap] .sd-lgs__l { white-space: pre-wrap; overflow-wrap: anywhere; }
.sd-lgs__l > span:last-child { overflow: hidden; text-overflow: ellipsis; }
.sd-lgs__t { color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-lgs__v { font-weight: 700; }
.sd-lgs__l[data-lv='debug'] { color: var(--mute); }
.sd-lgs__l[data-lv='warn'] .sd-lgs__v::before { content: '▲ '; font-size: 8px; vertical-align: 1px; }
.sd-lgs__l[data-lv='error'] { background: var(--soft); }
.sd-lgs__l[data-lv='error'] .sd-lgs__v::before { content: '● '; font-size: 8px; vertical-align: 1px; }
.sd-lgs mark { background: var(--strong); color: var(--on); border-radius: 2px; }
.sd-lgs__jump { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); padding: 7px 14px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; font-size: 12px; font-weight: 600; box-shadow: 0 8px 20px -8px rgb(0 0 0 / 0.6); animation: sd-lgs-up 200ms ease-out; white-space: nowrap; }
@keyframes sd-lgs-in { from { opacity: 0; } }
@keyframes sd-lgs-up { from { opacity: 0; transform: translate(-50%, 8px); } }
@media (prefers-reduced-motion: reduce) { .sd-lgs *, .sd-lgs *::before, .sd-lgs *::after { animation: none !important; transition: none !important; } }Overview
New lines append at the bottom and the view follows them. Scroll up (or press Pause) and it stops following, counting new lines on a “Jump to latest” pill.
Level chips filter; search highlights matches. Error lines carry a marker, never colour alone.
When to use
- Deploy logs, job output, device consoles.
When not to use
- Searching history across days: use a log explorer.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/log-stream.json$
npx shadcn@latest add https://sundaeui.dev/r/log-stream.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/log-stream.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/log-stream.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy log-stream.tsx and log-stream.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LogStream } from '@/components/sundae/log-stream'
<LogStream />Props
| Prop | Type | Default |
|---|---|---|
source*Called on each tick for a new line. | () => LogLine | — |
intervalMilliseconds between lines. | number | 700 |
maxLines kept. | number | 200 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The log is role="log" so additions are announced politely; pausing stops announcements too.
- Level filters are toggle buttons.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lgs; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Log Stream free?
- Yes. Log Stream is free for personal and commercial projects, with full source on this page.
- How do I install Log Stream?
- Run npx shadcn@latest add https://sundaeui.dev/r/log-stream.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Log Stream support keyboard and screen readers?
- The log is role="log" so additions are announced politely; pausing stops announcements too. Level filters are toggle buttons.
- Does Log Stream respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Webhook Event Log
Data · RefinedJSON Viewer
Data · RefinedQuery Runner
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined