# Log Viewer

> Streaming log panel with severity-colored lines, timestamps and sources, a level filter with counts, and a Follow toggle that tails new lines and unpins when you scroll up. Ships composable parts.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/log-viewer).

- Page, with a live preview: https://shadcn.exchange/r/hirael/log-viewer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/log-viewer-light-027a51e58c856ae4.png
- Registry item: https://hirael.com/r/log-viewer.json
- Categories: blocks, cloud
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/log-viewer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `empty`, `toggle`, `toggle-group`

## Files

### `registry/hirael/bases/radix/blocks/log-viewer/log-viewer.tsx`

```tsx
// Log Viewer from Hirael <https://hirael.com/blocks/cloud/log-viewer>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { ArrowDownToLine, Pause, Play, ScrollText } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/registry/hirael/bases/radix/ui/empty';
import { Toggle } from '@/registry/hirael/bases/radix/ui/toggle';
import { ToggleGroup, ToggleGroupItem } from '@/registry/hirael/bases/radix/ui/toggle-group';

export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';

const levelText: Record<LogLevel, string> = {
  debug: 'text-muted-foreground',
  info: 'text-info',
  warn: 'text-warning',
  error: 'text-destructive',
  success: 'text-success',
};

const levelLabel: Record<LogLevel, string> = {
  debug: 'DBG',
  info: 'INF',
  warn: 'WRN',
  error: 'ERR',
  success: 'OK ',
};

interface LogViewerProps extends React.ComponentProps<'div'> {
  /** Keep the view pinned to the newest line as content grows (tail -f). */
  follow?: boolean;
}

const LogViewer = ({ follow = true, className, children, ...props }: LogViewerProps) => {
  const ref = React.useRef<HTMLDivElement>(null);
  const pinnedRef = React.useRef(true);

  const onScroll = React.useCallback(() => {
    const el = ref.current;
    if (!el) return;
    pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
  }, []);

  React.useEffect(() => {
    if (follow) pinnedRef.current = true;
  }, [follow]);

  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el || !follow || !pinnedRef.current) return;
    el.scrollTop = el.scrollHeight;
  });

  return (
    <div
      ref={ref}
      data-slot="log-viewer"
      onScroll={onScroll}
      className={cn(
        'max-h-72 overflow-auto rounded-lg border border-border bg-card p-3 font-mono text-xs leading-relaxed',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  );
};

interface LogLineProps extends React.ComponentProps<'div'> {
  level?: LogLevel;
  time?: React.ReactNode;
  source?: React.ReactNode;
}

const LogLine = ({ level = 'info', time, source, className, children, ...props }: LogLineProps) => {
  return (
    <div
      data-slot="log-line"
      data-level={level}
      className={cn('flex gap-2 px-1 py-0.5 break-words whitespace-pre-wrap', 'hover:bg-muted/50', className)}
      {...props}
    >
      {time ? <span className="shrink-0 text-muted-foreground tabular-nums">{time}</span> : null}
      <span className={cn('shrink-0 font-medium select-none', levelText[level])} aria-label={level}>
        {levelLabel[level]}
      </span>
      {source ? <span className="shrink-0 text-muted-foreground/80">{source}</span> : null}
      <span className="min-w-0 text-foreground">{children}</span>
    </div>
  );
};

export { LogViewer, LogLine };

const ENTER =
  'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
  'animate-in fade-in slide-in-from-bottom-1 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

interface LogEntry {
  id: number;
  time: string;
  level: LogLevel;
  source: string;
  message: string;
}

const INITIAL_LOGS: Omit<LogEntry, 'id'>[] = [
  { time: '12:04:01', level: 'info', source: '[api]', message: 'listening on :8080' },
  { time: '12:04:01', level: 'debug', source: '[db]', message: 'pool acquired (8 idle, 2 active)' },
  { time: '12:04:02', level: 'success', source: '[api]', message: 'GET /health 200 3ms' },
  { time: '12:04:03', level: 'info', source: '[worker]', message: 'job 4821 started (resize-image)' },
  { time: '12:04:03', level: 'warn', source: '[cache]', message: 'key eviction: memory at 82%' },
  { time: '12:04:04', level: 'info', source: '[api]', message: 'POST /v1/deploy 202 41ms' },
  { time: '12:04:05', level: 'error', source: '[worker]', message: 'job 4821 failed: upstream timeout after 30s' },
  { time: '12:04:05', level: 'warn', source: '[worker]', message: 'retry 1/3 scheduled in 2s' },
  { time: '12:04:07', level: 'success', source: '[worker]', message: 'job 4821 recovered on retry' },
  { time: '12:04:08', level: 'debug', source: '[db]', message: 'slow query 214ms: SELECT * FROM events' },
];

const INCOMING_LOGS: Omit<LogEntry, 'id' | 'time'>[] = [
  { level: 'success', source: '[api]', message: 'GET /v1/projects 200 18ms' },
  { level: 'info', source: '[worker]', message: 'job 4822 started (send-digest)' },
  { level: 'warn', source: '[api]', message: 'rate limit at 90% for key pk_live_7Qx' },
  { level: 'success', source: '[worker]', message: 'job 4822 finished in 1.2s' },
  { level: 'debug', source: '[cache]', message: 'hit ratio 0.94 over last 60s' },
  { level: 'error', source: '[api]', message: 'POST /v1/webhooks 502 upstream closed connection' },
  { level: 'info', source: '[db]', message: 'checkpoint complete (412 buffers)' },
];

const START_SECONDS = 12 * 3600 + 4 * 60 + 8;
const MAX_LINES = 80;

const formatClock = (total: number) =>
  [Math.floor(total / 3600), Math.floor((total % 3600) / 60), total % 60]
    .map((part) => String(part).padStart(2, '0'))
    .join(':');

type LogFilter = 'all' | 'info' | 'warn' | 'error';

const FILTERS: { value: LogFilter; label: string; levels: LogLevel[] | null }[] = [
  { value: 'all', label: 'All', levels: null },
  { value: 'info', label: 'Info', levels: ['debug', 'info', 'success'] },
  { value: 'warn', label: 'Warn', levels: ['warn'] },
  { value: 'error', label: 'Error', levels: ['error'] },
];

const matchesFilter = (entry: LogEntry, filter: LogFilter) => {
  const levels = FILTERS.find((option) => option.value === filter)?.levels;

  return !levels || levels.includes(entry.level);
};

const LogViewerBlock = () => {
  const [logs, setLogs] = React.useState<LogEntry[]>(() => INITIAL_LOGS.map((entry, id) => ({ ...entry, id })));
  const [filter, setFilter] = React.useState<LogFilter>('all');
  const [streaming, setStreaming] = React.useState(true);
  const [follow, setFollow] = React.useState(true);

  React.useEffect(() => {
    if (!streaming) return;
    const timer = window.setInterval(() => {
      setLogs((current) => {
        const id = (current[current.length - 1]?.id ?? -1) + 1;
        const incoming = INCOMING_LOGS[id % INCOMING_LOGS.length];

        return [...current, { ...incoming, id, time: formatClock(START_SECONDS + id) }].slice(-MAX_LINES);
      });
    }, 1800);

    return () => window.clearInterval(timer);
  }, [streaming]);

  const visible = logs.filter((entry) => matchesFilter(entry, filter));
  const activeLabel = FILTERS.find((option) => option.value === filter)?.label.toLowerCase();

  return (
    <section data-slot="log-viewer-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
      <div data-slot="log-viewer-panel" className={cn(ENTER, 'flex w-full max-w-2xl flex-col gap-3')}>
        <div data-slot="log-viewer-toolbar" className="flex flex-wrap items-center justify-between gap-2">
          <ToggleGroup
            type="single"
            size="sm"
            variant="outline"
            value={filter}
            onValueChange={(value) => {
              if (value) setFilter(value as LogFilter);
            }}
            aria-label="Filter by level"
          >
            {FILTERS.map((option) => (
              <ToggleGroupItem key={option.value} value={option.value}>
                {option.label}
                <span className="text-muted-foreground tabular-nums">
                  {logs.filter((entry) => matchesFilter(entry, option.value)).length}
                </span>
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
          <div className="flex items-center gap-2">
            <Button type="button" size="sm" variant="outline" onClick={() => setStreaming((value) => !value)}>
              {streaming ? <Pause aria-hidden /> : <Play aria-hidden />}
              {streaming ? 'Pause' : 'Resume'}
              <span
                aria-hidden
                className={cn(
                  'size-1.5 rounded-full transition-colors',
                  streaming ? 'animate-pulse bg-primary motion-reduce:animate-none' : 'bg-muted-foreground/40',
                )}
              />
            </Button>
            <Toggle size="sm" variant="outline" pressed={follow} onPressedChange={setFollow}>
              <ArrowDownToLine aria-hidden />
              Follow
            </Toggle>
          </div>
        </div>
        <LogViewer follow={follow} dir="ltr" className="h-72">
          {visible.length ? (
            <div key={filter} className={SWAP}>
              {visible.map((entry) => (
                <LogLine key={entry.id} time={entry.time} level={entry.level} source={entry.source}>
                  {entry.message}
                </LogLine>
              ))}
            </div>
          ) : (
            <div key={`${filter}-empty`} className={cn(SWAP, 'flex h-full font-sans')}>
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <ScrollText />
                  </EmptyMedia>
                  <EmptyTitle>No {activeLabel} lines yet</EmptyTitle>
                  <EmptyDescription>
                    {streaming ? 'New lines show up here as they arrive.' : 'Resume the stream to pick up new lines.'}
                  </EmptyDescription>
                </EmptyHeader>
              </Empty>
            </div>
          )}
        </LogViewer>
      </div>
    </section>
  );
};

export default LogViewerBlock;
```
