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.
npx shadcn@latest add @paragon/log-lineAlso installs: copy-button, tooltip
"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>
);
}