Log Line
Data Display

Log Line

A structured log row with a fixed-width level badge, tabular timestamp, search-hit highlighting, hover-revealed copy, and a grid-rows expandable panel for structured fields and the raw line.

Install

npx shadcn@latest add @paragon/log-line

Also installs: copy-button, tooltip

log-line.tsx

"use client";

import * as React from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { CopyButton } from "@/registry/paragon/ui/copy-button";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";

export interface LogLineProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  level: LogLevel;
  /** Display timestamp, e.g. "14:32:08.412". Rendered as-is. */
  timestamp: string;
  /** Full timestamp for the hover tooltip (ISO with date, timezone…). */
  timestampDetail?: string;
  message: string;
  /** Originating service or module, shown as a chip. */
  source?: string;
  /** Structured fields revealed in the expandable detail panel. */
  meta?: Record<string, string | number | boolean | null>;
  /** Raw line for the copy action. Defaults to a composed line. */
  raw?: string;
  /** Substring to highlight in the message (search hit). */
  highlight?: string;
  /** Wrap long messages instead of truncating. */
  wrap?: boolean;
  defaultExpanded?: boolean;
  /** Disables the expand/press motion. */
  static?: boolean;
}

const LEVEL_STYLE: Record<LogLevel, string> = {
  trace: "bg-muted/50 text-muted-foreground/80",
  debug: "bg-muted/70 text-muted-foreground",
  info: "bg-muted text-foreground",
  warn: "bg-warning/12 text-warning",
  error: "bg-destructive/10 text-destructive",
  fatal: "bg-destructive text-destructive-foreground",
};

function escapeRegExp(value: string) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function Highlighted({ text, term }: { text: string; term?: string }) {
  const query = term?.trim();
  if (!query) return <>{text}</>;
  const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "gi"));
  return (
    <>
      {parts.map((part, i) =>
        part.toLowerCase() === query.toLowerCase() ? (
          <mark
            key={i}
            className="rounded-[2px] bg-warning/30 px-px text-inherit"
          >
            {part}
          </mark>
        ) : (
          <React.Fragment key={i}>{part}</React.Fragment>
        ),
      )}
    </>
  );
}

/**
 * One structured log row — the primitive for log viewers and error
 * consoles. A fixed-width level badge, tabular timestamp (full detail on
 * hover), source chip, and a truncating (or wrapping) monospace message
 * with search-hit highlighting. Rows with structured fields expand through
 * grid-rows into a field grid plus the raw line; the copy affordance
 * surfaces on hover or focus. Log rows render hundreds of times a day, so
 * the row itself never animates in — only the expansion moves.
 */
export function LogLine({
  level,
  timestamp,
  timestampDetail,
  message,
  source,
  meta,
  raw,
  highlight,
  wrap = false,
  defaultExpanded = false,
  static: isStatic = false,
  className,
  ...props
}: LogLineProps) {
  const [expanded, setExpanded] = React.useState(defaultExpanded);
  const detailId = React.useId();
  const fields = meta ? Object.entries(meta) : [];
  const expandable = fields.length > 0;
  const rawLine =
    raw ??
    `${timestamp} ${level.toUpperCase().padEnd(5)} ${source ? `[${source}] ` : ""}${message}`;

  const timeEl = (
    <span className="shrink-0 font-mono text-[11px] text-muted-foreground/80 tabular-nums">
      {timestamp}
    </span>
  );

  return (
    <div
      data-slot="log-line"
      data-level={level}
      className={cn(
        "group/log min-w-0",
        (level === "error" || level === "fatal") && "bg-destructive/[0.04]",
        level === "warn" && "bg-warning/[0.05]",
        className,
      )}
      {...props}
    >
      <div
        className={cn(
          "flex min-w-0 items-start gap-2 px-2 py-1",
          "transition-colors duration-(--duration-fast) hover:bg-muted/40",
          expandable && "cursor-default",
        )}
        onClick={(event) => {
          // Row click toggles unless the click landed on a real control.
          if (!expandable) return;
          if ((event.target as HTMLElement).closest("button, a, [role=button]"))
            return;
          setExpanded((prev) => !prev);
        }}
      >
        {expandable ? (
          <button
            type="button"
            aria-expanded={expanded}
            aria-controls={detailId}
            aria-label={expanded ? "Collapse detail" : "Expand detail"}
            onClick={() => setExpanded((prev) => !prev)}
            className="relative mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground/70 transition-colors duration-(--duration-fast) hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-7 after:-translate-1/2"
          >
            <ChevronRight
              aria-hidden
              className={cn(
                "size-3",
                expanded && "rotate-90",
                !isStatic &&
                  "transition-[rotate] duration-(--duration-quick) ease-(--ease-out) motion-reduce:transition-none",
              )}
            />
          </button>
        ) : (
          <span aria-hidden className="mt-0.5 w-4 shrink-0" />
        )}

        {timestampDetail ? (
          <Tooltip>
            <TooltipTrigger asChild>
              <span className="shrink-0 cursor-default font-mono text-[11px] text-muted-foreground/80 tabular-nums underline decoration-transparent">
                {timestamp}
              </span>
            </TooltipTrigger>
            <TooltipContent side="top" className="font-mono tabular-nums">
              {timestampDetail}
            </TooltipContent>
          </Tooltip>
        ) : (
          timeEl
        )}

        <span
          className={cn(
            "flex w-12 shrink-0 items-center justify-center rounded-[4px] py-px font-mono text-[10px] font-semibold tracking-wide uppercase select-none",
            LEVEL_STYLE[level],
          )}
        >
          {level}
        </span>

        {source && (
          <span
            className="max-w-24 shrink-0 truncate font-mono text-[11px] text-muted-foreground"
            title={source}
          >
            {source}
          </span>
        )}

        <span
          className={cn(
            "min-w-0 flex-1 font-mono text-xs text-foreground",
            wrap ? "break-all whitespace-pre-wrap" : "truncate",
          )}
          title={wrap ? undefined : message}
        >
          <Highlighted text={message} term={highlight} />
        </span>

        <CopyButton
          value={rawLine}
          static={isStatic}
          className={cn(
            "-my-0.5 size-5 shrink-0 rounded-[5px] [&_svg]:size-3",
            "transition-[color,opacity] duration-(--duration-fast)",
            "pointer-fine:opacity-0 pointer-fine:group-hover/log:opacity-100 focus-visible:opacity-100",
          )}
        />
      </div>

      {expandable && (
        <div
          className={cn(
            "grid",
            expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
            !isStatic &&
              "transition-[grid-template-rows] motion-reduce:transition-none",
            !isStatic &&
              (expanded
                ? "duration-(--duration-base) ease-(--ease-out)"
                : "duration-(--duration-quick) ease-(--ease-exit)"),
          )}
        >
          <div id={detailId} inert={!expanded} className="min-h-0 overflow-hidden">
            <div className="mx-2 mb-1.5 ml-8 rounded-lg bg-muted/40 px-3 py-2.5">
              <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1">
                {fields.map(([key, value]) => (
                  <React.Fragment key={key}>
                    <dt className="font-mono text-[11px] text-muted-foreground">
                      {key}
                    </dt>
                    <dd className="min-w-0 font-mono text-[11px] break-all text-foreground tabular-nums">
                      {value === null ? "null" : String(value)}
                    </dd>
                  </React.Fragment>
                ))}
              </dl>
              <div className="relative mt-2 border-t pt-2">
                <p className="pr-8 font-mono text-[11px] break-all text-muted-foreground">
                  {rawLine}
                </p>
                <CopyButton
                  value={rawLine}
                  static={isStatic}
                  className="absolute top-1 right-0 size-6 [&_svg]:size-3"
                />
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}